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)
This commit is contained in:
98
Shared/LimitEnforcement.swift
Normal file
98
Shared/LimitEnforcement.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user