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

93
Shared/UsageLedger.swift Normal file
View File

@@ -0,0 +1,93 @@
//
// UsageLedger.swift
// OpenAppLock
//
import Foundation
/// What a limit rule has consumed on a given day. Written by the
/// DeviceActivity monitor (minutes) and shield-action extension (opens);
/// read by the app for display and enforcement.
struct RuleUsage: Codable, Equatable {
var minutesUsed = 0
var opensUsed = 0
}
/// Read access to per-rule, per-day usage.
protocol UsageReading: AnyObject {
func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage
}
extension UsageReading {
func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage {
usage(for: ruleID, onDayContaining: date, calendar: .current)
}
}
/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day
/// and rule. Old days are simply ignored; midnight needs no reset step.
final class UsageLedger: UsageReading {
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
/// "2026-06-12" calendar-date key so budgets roll over at midnight.
static func dayKey(for date: Date, calendar: Calendar = .current) -> String {
let parts = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
}
func usage(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
) -> RuleUsage {
guard let data = defaults.data(forKey: key(ruleID, date, calendar)),
let usage = try? JSONDecoder().decode(RuleUsage.self, from: data)
else { return RuleUsage() }
return usage
}
func setUsage(
_ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date,
calendar: Calendar = .current
) {
guard let data = try? JSONEncoder().encode(usage) else { return }
defaults.set(data, forKey: key(ruleID, date, calendar))
}
/// Threshold events report cumulative totals, so minutes only move up.
func recordMinutesUsed(
_ minutes: Int, for ruleID: UUID, onDayContaining date: Date,
calendar: Calendar = .current
) {
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
usage.minutesUsed = max(usage.minutesUsed, minutes)
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
}
@discardableResult
func recordOpen(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
) -> RuleUsage {
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
usage.opensUsed += 1
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
return usage
}
private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String {
"usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)"
}
}
/// Seedable in-memory usage for tests and UI-test scenarios.
final class MockUsageLedger: UsageReading {
var usageByRule: [UUID: RuleUsage] = [:]
func usage(
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar
) -> RuleUsage {
usageByRule[ruleID] ?? RuleUsage()
}
}