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:
93
Shared/UsageLedger.swift
Normal file
93
Shared/UsageLedger.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user