add astra draft
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user