124 lines
6.9 KiB
Swift
124 lines
6.9 KiB
Swift
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)
|
|
}
|
|
}
|