Files
OpenAppLock/Shared/LimitEnforcement.swift
Brendan Chen 1652c2f410 feat: enforce time and open limits in the background via Screen Time extensions
- Shared/ layer compiled into the app and three new extension targets:
  rule snapshots in the app group, the usage ledger, monitoring-plan
  naming, and LimitEnforcement (shared, unit-tested event reactions)
- RuleScheduler mirrors rules to the app group and reconciles
  DeviceActivity monitoring: one daily 00:00-23:59 activity per limit
  rule, with a cumulative usage-threshold event per budget minute for
  time limits; activities restart only when their configuration changes
  (a restart resets threshold accounting)
- OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets,
  records usage minutes, shields at the budget, re-shields when a
  granted open session ends
- OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times
  today' with an 'Open (Y left)' secondary button
- OpenAppLockShieldAction: an Open press spends one open, lifts the
  rule's shield, and starts the ~15-minute one-shot session
- extensions are classic NSExtension app extensions (the ExtensionKit
  product type expects an @main entry and made the app fail to install);
  shield-store tracking moved to app-group defaults so the app and
  extensions see one consistent set
- maps iOS 26's new .approvedWithDataAccess authorization status to
  approved (it previously fell through @unknown default to notDetermined)
- shares the OpenAppLock scheme (Xcode dropped the autocreated one when
  targets were added)
2026-06-12 21:06:08 -04:00

99 lines
3.8 KiB
Swift

//
// LimitEnforcement.swift
// OpenAppLock
//
import Foundation
/// Shared reactions to Screen Time events for limit rules, driven by the
/// snapshot store and usage ledger. The DeviceActivity monitor and shield
/// extensions call these; keeping the logic here makes it unit-testable from
/// the app target.
struct LimitEnforcement {
let snapshots: RuleSnapshotStore
let ledger: UsageLedger
let shields: ShieldApplying
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
/// proactively shielded on enabled days so the shield can count opens;
/// time-limit rules start the day unshielded.
func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
!snapshot.isPaused(at: now)
else { return }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
switch snapshot.kind {
case .schedule:
break
case .openLimit:
if snapshot.isScheduledToday(at: now, calendar: calendar) {
shield(snapshot)
} else {
shields.clearShield(ruleID: ruleID)
}
case .timeLimit:
if snapshot.limitReached(given: usage),
snapshot.isScheduledToday(at: now, calendar: calendar) {
shield(snapshot)
} else {
shields.clearShield(ruleID: ruleID)
}
}
}
/// A cumulative usage checkpoint fired for a time-limit rule.
func handleUsageMinutes(
_ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) {
ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar)
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .timeLimit,
!snapshot.isPaused(at: now),
snapshot.isScheduledToday(at: now, calendar: calendar)
else { return }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
if snapshot.limitReached(given: usage) {
shield(snapshot)
}
}
/// The wall-clock session granted by an "Open" press ended; the shield
/// returns so the next open costs another press.
func handleOpenSessionEnded(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit,
!snapshot.isPaused(at: now),
snapshot.isScheduledToday(at: now, calendar: calendar)
else { return }
shield(snapshot)
}
/// "Open" pressed on the shield. Spends one open and lifts the rule's
/// shield when the budget allows; returns whether a session was granted.
@discardableResult
func handleOpenRequest(
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
) -> Bool {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
snapshot.kind == .openLimit,
!snapshot.isPaused(at: now)
else { return false }
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
guard !snapshot.limitReached(given: usage) else { return false }
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
shields.clearShield(ruleID: ruleID)
return true
}
private func shield(_ snapshot: RuleSnapshot) {
shields.applyShield(
ruleID: snapshot.id,
selectionData: snapshot.selectionData,
mode: .block,
blockAdultContent: snapshot.blockAdultContent
)
}
}