add astra draft

This commit is contained in:
2026-09-17 17:59:34 +02:00
parent 88588dcfcc
commit 41458afc76
38 changed files with 2392 additions and 0 deletions
+123
View File
@@ -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)
}
}
+53
View File
@@ -0,0 +1,53 @@
import Darwin
import FamilyControls
import Foundation
import PolicyCore
struct NativeState: Codable {
var snapshot: Snapshot?
var selection = FamilyActivitySelection()
var selectionRevision = UUID().uuidString
var observation = Observation()
var generation: String?
var policyKey: String?
var thresholds: [Int] = []
var monitoringReady = false
var shielded = false
var lastEvent = "Not configured"
var monitoringError: String?
}
enum LocalError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self { case .message(let value): return value }
}
}
enum StateStore {
/// A process-wide UserDefaults cache is not sufficient: the app and extension both write.
/// Lock one file while atomically replacing another. Corrupt state is never reset to defaults.
static func withState<T>(_ operation: (inout NativeState) throws -> T) throws -> T {
guard let group = Bundle.main.object(forInfoDictionaryKey: "APCAppGroup") as? String,
let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
throw LocalError.message("App Group unavailable. Check signing and APC_APP_GROUP on all targets.")
}
let lockPath = directory.appendingPathComponent("state.lock").path
let fd = open(lockPath, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
guard fd >= 0 else { throw LocalError.message("Cannot open the shared-state lock") }
defer { close(fd) }
guard flock(fd, LOCK_EX) == 0 else { throw LocalError.message("Cannot lock shared state") }
defer { flock(fd, LOCK_UN) }
let file = directory.appendingPathComponent("state.json")
var state = NativeState()
if FileManager.default.fileExists(atPath: file.path) {
state = try WireCoding.decoder().decode(NativeState.self, from: Data(contentsOf: file))
}
let result = try operation(&state)
try WireCoding.encoder().encode(state).write(to: file, options: .atomic)
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], atPath: file.path
)
return result
}
}