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)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:06:08 -04:00
parent df6b7b689d
commit 443b37c5e1
29 changed files with 1686 additions and 24 deletions

75
Shared/RuleSnapshot.swift Normal file
View File

@@ -0,0 +1,75 @@
//
// RuleSnapshot.swift
// OpenAppLock
//
import Foundation
/// Codable mirror of a rule, written to the app group by the app whenever
/// rules change so the Screen Time extensions (which cannot open the
/// SwiftData store) know what to enforce.
struct RuleSnapshot: Codable, Equatable {
var id: UUID
var name: String
var kindRaw: String
var isEnabled: Bool
var hardMode: Bool
var blockAdultContent: Bool
var selectionModeRaw: String
var selectionData: Data?
var dayNumbers: [Int]
var dailyLimitMinutes: Int
var maxOpens: Int
var pausedUntil: Date?
var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule }
var days: Set<Weekday> { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
return false
}
return days.contains(weekday)
}
/// Whether the given usage exhausts this rule's daily budget.
func limitReached(given usage: RuleUsage) -> Bool {
switch kind {
case .schedule: false
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
case .openLimit: usage.opensUsed >= maxOpens
}
}
/// Whether the user unblocked this rule for the rest of the day.
func isPaused(at now: Date) -> Bool {
guard let pausedUntil else { return false }
return pausedUntil > now
}
}
/// Persistence for the rule mirror in the shared app-group defaults.
final class RuleSnapshotStore {
private static let key = "ruleSnapshots"
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
func save(_ snapshots: [RuleSnapshot]) {
guard let data = try? JSONEncoder().encode(snapshots) else { return }
defaults.set(data, forKey: Self.key)
}
func load() -> [RuleSnapshot] {
guard let data = defaults.data(forKey: Self.key),
let snapshots = try? JSONDecoder().decode([RuleSnapshot].self, from: data)
else { return [] }
return snapshots
}
func snapshot(for ruleID: UUID) -> RuleSnapshot? {
load().first { $0.id == ruleID }
}
}