import CryptoKit import DeviceActivity import FamilyControls import Foundation import ManagedSettings import PolicyCore /// Native adapter. PolicyCore is unit-tested; Apple callback delivery still needs device tests. enum Enforcement { private static let prefix = "apc.window." private static var settings: ManagedSettingsStore { ManagedSettingsStore(named: ManagedSettingsStore.Name("apc.entertainment")) } private static func shield(_ selection: FamilyActivitySelection, enabled: Bool) { settings.shield.applications = enabled ? selection.applicationTokens : nil } static func apply(snapshot: Snapshot? = nil, selection: FamilyActivitySelection? = nil) throws -> DeviceStatus { try StateStore.withState { state in if let snapshot { try snapshot.validate() guard abs(Date().timeIntervalSince1970 - snapshot.serverTime) < 300 else { throw LocalError.message("Phone/server clocks differ by more than five minutes") } state.snapshot = snapshot } if let selection { guard !selection.applicationTokens.isEmpty, selection.categoryTokens.isEmpty, selection.webDomainTokens.isEmpty else { throw LocalError.message("Select individual apps only; whole categories and websites are deferred in v0") } state.selection = selection state.selectionRevision = UUID().uuidString } guard let snapshot = state.snapshot, !state.selection.applicationTokens.isEmpty else { return status(state, kind: "not_configured", detail: "Pair and select individual apps with a guardian") } try snapshot.validate() let now = Date() let policy = snapshot.policy state.observation.roll(to: policy.day(at: now)) let keyData = try WireCoding.encoder().encode(policy) + Data(state.selectionRevision.utf8) let key = SHA256.hash(data: keyData).map { String(format: "%02x", $0) }.joined() let budget = policy.dailyMinutes + snapshot.bonus(at: now) let center = DeviceActivityCenter() let activity = state.generation.map { DeviceActivityName(prefix + $0) } let registered = activity.map { center.activities.contains($0) } ?? false let needsPlan = state.policyKey != key || !state.monitoringReady || !registered || (budget > 0 && !state.thresholds.contains(budget)) if needsPlan { // Put the shield on before changing the monitor. A failed registration stays blocked. shield(state.selection, enabled: true) state.shielded = true state.monitoringReady = false state.generation = UUID().uuidString state.thresholds = policy.thresholds(startingBonus: snapshot.bonus(at: now)) let name = DeviceActivityName(prefix + state.generation!) var start = DateComponents(hour: policy.allowedStart / 60, minute: policy.allowedStart % 60) var end = DateComponents(hour: policy.allowedEnd / 60, minute: policy.allowedEnd % 60) start.timeZone = policy.calendar.timeZone; end.timeZone = policy.calendar.timeZone let schedule = DeviceActivitySchedule(intervalStart: start, intervalEnd: end, repeats: true) let events = Dictionary(uniqueKeysWithValues: state.thresholds.map { minutes in (DeviceActivityEvent.Name("minutes.\(minutes)"), DeviceActivityEvent( applications: state.selection.applicationTokens, threshold: DateComponents(minute: minutes), includesPastActivity: true )) }) do { try center.startMonitoring(name, during: schedule, events: events) // Stop only our own obsolete schedules, after the replacement exists. center.stopMonitoring(center.activities.filter { $0.rawValue.hasPrefix(prefix) && $0 != name }) state.monitoringReady = true state.monitoringError = nil state.policyKey = key state.lastEvent = "Monitoring registered; awaiting system callbacks" } catch { center.stopMonitoring(center.activities.filter { $0.rawValue.hasPrefix(prefix) }) state.monitoringError = String(String(describing: error).prefix(300)) } } let decision = Decision.evaluate(snapshot, observation: state.observation, now: now) state.shielded = !state.monitoringReady || decision.shielded shield(state.selection, enabled: state.shielded) return status(state, kind: state.monitoringReady ? "monitoring" : "error", detail: state.monitoringError ?? "\(decision.reason). \(state.lastEvent)") } } static func callback(activity: DeviceActivityName, event: DeviceActivityEvent.Name? = nil, ended: Bool = false) throws { try StateStore.withState { state in guard let generation = state.generation, let snapshot = state.snapshot, activity.rawValue == prefix + generation else { return } // Ignore obsolete monitors. let now = Date() let day = snapshot.policy.day(at: now) state.observation.roll(to: day) if let event, event.rawValue.hasPrefix("minutes."), let minutes = Int(event.rawValue.dropFirst("minutes.".count)), state.thresholds.contains(minutes), snapshot.policy.isAllowed(at: now) { state.observation.record(threshold: minutes, day: day) state.lastEvent = "Reached \(minutes)-minute usage threshold" } else if event == nil { state.lastEvent = ended ? "Allowed window ended" : "Allowed window started" } let decision = Decision.evaluate(snapshot, observation: state.observation, now: now) state.shielded = ended || !state.monitoringReady || decision.shielded shield(state.selection, enabled: state.shielded) } } static func report(kind: String, detail: String) throws -> DeviceStatus { try StateStore.withState { state in status(state, kind: kind, detail: detail) } } private static func status(_ state: NativeState, kind: String, detail: String) -> DeviceStatus { DeviceStatus(policyRevision: state.snapshot?.policyRevision ?? 0, state: kind, shielded: state.shielded, detail: String(detail.prefix(500)), consumedLowerBound: state.observation.reachedMinutes, usageDay: state.observation.day) } }