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:
2026-06-12 21:06:08 -04:00
parent 8e3b8b70f5
commit 1652c2f410
29 changed files with 1686 additions and 24 deletions

View 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
}
}
}