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,101 @@
//
// ShieldActionExtension.swift
// OpenAppLockShieldAction
//
import DeviceActivity
import Foundation
import ManagedSettings
/// Handles shield button presses. The secondary "Open" button on an
/// open-limit shield spends one open, lifts the rule's shield, and starts a
/// one-shot DeviceActivity session after which the monitor extension
/// re-shields.
final class ShieldActionExtension: ShieldActionDelegate {
override func handle(
action: ShieldAction, for application: ApplicationToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .primaryButtonPressed:
completionHandler(.close)
case .secondaryButtonPressed:
completionHandler(handleOpenPress(applicationToken: application))
case .firstSecondarySubmenuItemPressed, .secondSecondarySubmenuItemPressed,
.thirdSecondarySubmenuItemPressed:
// Our shields define no submenu items.
completionHandler(.close)
@unknown default:
completionHandler(.close)
}
}
override func handle(
action: ShieldAction, for webDomain: WebDomainToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
completionHandler(.close)
}
override func handle(
action: ShieldAction, for category: ActivityCategoryToken,
completionHandler: @escaping (ShieldActionResponse) -> Void
) {
switch action {
case .secondaryButtonPressed:
if let snapshot = ShieldLookup.openLimitSnapshot(
containingCategory: category, in: RuleSnapshotStore().load()) {
completionHandler(grantOpen(ruleID: snapshot.id))
} else {
completionHandler(.close)
}
default:
completionHandler(.close)
}
}
private func handleOpenPress(applicationToken: ApplicationToken) -> ShieldActionResponse {
guard
let snapshot = ShieldLookup.openLimitSnapshot(
containingApplication: applicationToken, in: RuleSnapshotStore().load())
else { return .close }
return grantOpen(ruleID: snapshot.id)
}
private func grantOpen(ruleID: UUID) -> ShieldActionResponse {
let enforcement = LimitEnforcement(
snapshots: RuleSnapshotStore(),
ledger: UsageLedger(),
shields: ManagedSettingsShieldController()
)
guard enforcement.handleOpenRequest(ruleID: ruleID) else {
// Out of opens; keep the shield up (it re-renders with the
// exhausted message on next presentation).
return .defer
}
startOpenSession(ruleID: ruleID)
return .none
}
/// Times the granted open with a one-shot activity. One extra minute over
/// the advertised session keeps the interval above DeviceActivity's
/// 15-minute minimum.
private func startOpenSession(ruleID: UUID) {
let calendar = Calendar.current
let now = Date.now
guard
let end = calendar.date(
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
else { return }
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: calendar.dateComponents(components, from: now),
intervalEnd: calendar.dateComponents(components, from: end),
repeats: false
)
try? DeviceActivityCenter().startMonitoring(
DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)),
during: schedule
)
}
}