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:
61
Shared/MonitoringPlan.swift
Normal file
61
Shared/MonitoringPlan.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// MonitoringPlan.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Naming conventions and event layouts shared by the app (which starts
|
||||
/// DeviceActivity monitoring) and the monitor extension (which decodes what
|
||||
/// fired).
|
||||
enum MonitoringPlan {
|
||||
private static let dailyPrefix = "rule-"
|
||||
private static let sessionPrefix = "open-session-"
|
||||
private static let minutePrefix = "minutes-"
|
||||
|
||||
/// Wall-clock length of one granted open. 15 minutes is DeviceActivity's
|
||||
/// minimum schedule interval, so an "open" lasts at most this long before
|
||||
/// the shield returns.
|
||||
static let openSessionMinutes = 15
|
||||
|
||||
/// The always-on, midnight-to-midnight activity tracking a rule's day.
|
||||
static func dailyActivityName(for ruleID: UUID) -> String {
|
||||
dailyPrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
/// The one-shot activity timing a granted open.
|
||||
static func sessionActivityName(for ruleID: UUID) -> String {
|
||||
sessionPrefix + ruleID.uuidString
|
||||
}
|
||||
|
||||
static func ruleID(fromDailyActivityName name: String) -> UUID? {
|
||||
guard name.hasPrefix(dailyPrefix) else { return nil }
|
||||
return UUID(uuidString: String(name.dropFirst(dailyPrefix.count)))
|
||||
}
|
||||
|
||||
static func ruleID(fromSessionActivityName name: String) -> UUID? {
|
||||
guard name.hasPrefix(sessionPrefix) else { return nil }
|
||||
return UUID(uuidString: String(name.dropFirst(sessionPrefix.count)))
|
||||
}
|
||||
|
||||
static func minuteEventName(for minutes: Int) -> String {
|
||||
minutePrefix + String(minutes)
|
||||
}
|
||||
|
||||
static func minutes(fromEventName name: String) -> Int? {
|
||||
guard name.hasPrefix(minutePrefix) else { return nil }
|
||||
return Int(name.dropFirst(minutePrefix.count))
|
||||
}
|
||||
|
||||
/// Cumulative-usage checkpoints for a time-limit rule: one event per
|
||||
/// minute up to the budget so remaining time can be displayed live; the
|
||||
/// final one doubles as the block trigger. (Budgets cap at 240 minutes,
|
||||
/// comfortably inside DeviceActivity's event capacity.)
|
||||
static func minuteEvents(forLimit limitMinutes: Int) -> [String: Int] {
|
||||
Dictionary(
|
||||
uniqueKeysWithValues: (1...max(1, limitMinutes)).map {
|
||||
(minuteEventName(for: $0), $0)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user