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:
172
OpenAppLock/Services/RuleScheduler.swift
Normal file
172
OpenAppLock/Services/RuleScheduler.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// RuleScheduler.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import DeviceActivity
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
|
||||
/// Abstracts `DeviceActivityCenter` so scheduling can be unit-tested.
|
||||
protocol ActivityMonitoring: AnyObject {
|
||||
/// Starts (or replaces) an always-on, midnight-to-midnight repeating
|
||||
/// activity. `eventMinutes` maps event names to cumulative usage
|
||||
/// thresholds (in minutes) over the rule's selection.
|
||||
func startDailyMonitoring(
|
||||
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
||||
) throws
|
||||
func stopMonitoring(names: [String])
|
||||
var monitoredNames: [String] { get }
|
||||
}
|
||||
|
||||
/// Mirrors rules into the shared snapshot store and reconciles
|
||||
/// DeviceActivity monitoring with the enabled limit rules: each one gets a
|
||||
/// daily activity (time limits with one usage checkpoint per budget minute).
|
||||
/// Activities are only restarted when their configuration changes — a
|
||||
/// restart resets checkpoint accounting to "usage from now on".
|
||||
final class RuleScheduler {
|
||||
private static let fingerprintsKey = "monitoringFingerprints"
|
||||
|
||||
private let monitor: ActivityMonitoring
|
||||
private let snapshots: RuleSnapshotStore
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(
|
||||
monitor: ActivityMonitoring,
|
||||
snapshots: RuleSnapshotStore = RuleSnapshotStore(),
|
||||
defaults: UserDefaults = AppGroup.defaults
|
||||
) {
|
||||
self.monitor = monitor
|
||||
self.snapshots = snapshots
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func sync(rules: [BlockingRule], at now: Date = .now) {
|
||||
snapshots.save(rules.map(RuleSnapshot.init))
|
||||
|
||||
var fingerprints = storedFingerprints
|
||||
var desiredNames: Set<String> = []
|
||||
|
||||
for rule in rules where rule.kind != .schedule {
|
||||
guard rule.isEnabled, let selectionData = rule.appList?.selectionData else { continue }
|
||||
let name = MonitoringPlan.dailyActivityName(for: rule.id)
|
||||
desiredNames.insert(name)
|
||||
|
||||
let events =
|
||||
rule.kind == .timeLimit
|
||||
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
|
||||
: [:]
|
||||
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
|
||||
+ "\(selectionData.hashValue)"
|
||||
guard fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
|
||||
else { continue }
|
||||
|
||||
do {
|
||||
try monitor.startDailyMonitoring(
|
||||
name: name, selectionData: selectionData, eventMinutes: events)
|
||||
fingerprints[name] = fingerprint
|
||||
} catch {
|
||||
// Monitoring is best-effort here; the next sync retries.
|
||||
// (Throws on the simulator and when authorization is missing.)
|
||||
}
|
||||
}
|
||||
|
||||
let stale = monitor.monitoredNames.filter {
|
||||
MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil && !desiredNames.contains($0)
|
||||
}
|
||||
if !stale.isEmpty {
|
||||
monitor.stopMonitoring(names: stale)
|
||||
for name in stale {
|
||||
fingerprints[name] = nil
|
||||
}
|
||||
}
|
||||
storedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
private var storedFingerprints: [String: String] {
|
||||
get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] }
|
||||
set { defaults.set(newValue, forKey: Self.fingerprintsKey) }
|
||||
}
|
||||
}
|
||||
|
||||
extension RuleSnapshot {
|
||||
init(rule: BlockingRule) {
|
||||
self.init(
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
kindRaw: rule.kindRaw,
|
||||
isEnabled: rule.isEnabled,
|
||||
hardMode: rule.hardMode,
|
||||
blockAdultContent: rule.blockAdultContent,
|
||||
selectionModeRaw: rule.selectionModeRaw,
|
||||
selectionData: rule.appList?.selectionData,
|
||||
dayNumbers: rule.dayNumbers,
|
||||
dailyLimitMinutes: rule.dailyLimitMinutes,
|
||||
maxOpens: rule.maxOpens,
|
||||
pausedUntil: rule.pausedUntil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Real DeviceActivity scheduling. Each daily activity repeats from midnight
|
||||
/// to 23:59 with usage-threshold events over the rule's selection.
|
||||
final class DeviceActivityCenterMonitor: ActivityMonitoring {
|
||||
private let center = DeviceActivityCenter()
|
||||
|
||||
var monitoredNames: [String] {
|
||||
center.activities.map(\.rawValue)
|
||||
}
|
||||
|
||||
func startDailyMonitoring(
|
||||
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
||||
) throws {
|
||||
let selection = AppSelectionCodec.decode(selectionData)
|
||||
let schedule = DeviceActivitySchedule(
|
||||
intervalStart: DateComponents(hour: 0, minute: 0),
|
||||
intervalEnd: DateComponents(hour: 23, minute: 59),
|
||||
repeats: true
|
||||
)
|
||||
let events = Dictionary(
|
||||
uniqueKeysWithValues: eventMinutes.map { eventName, minutes in
|
||||
(
|
||||
DeviceActivityEvent.Name(eventName),
|
||||
DeviceActivityEvent(
|
||||
applications: selection.applicationTokens,
|
||||
categories: selection.categoryTokens,
|
||||
webDomains: selection.webDomainTokens,
|
||||
threshold: DateComponents(minute: minutes)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
|
||||
}
|
||||
|
||||
func stopMonitoring(names: [String]) {
|
||||
center.stopMonitoring(names.map { DeviceActivityName($0) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Records scheduling calls for tests.
|
||||
final class MockActivityMonitor: ActivityMonitoring {
|
||||
private(set) var startedEvents: [String: [String: Int]] = [:]
|
||||
private(set) var startCallCount = 0
|
||||
private(set) var monitoredNames: [String] = []
|
||||
|
||||
func startDailyMonitoring(
|
||||
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
||||
) throws {
|
||||
startCallCount += 1
|
||||
startedEvents[name] = eventMinutes
|
||||
if !monitoredNames.contains(name) {
|
||||
monitoredNames.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring(names: [String]) {
|
||||
monitoredNames.removeAll(where: names.contains)
|
||||
for name in names {
|
||||
startedEvents[name] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user