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
+152
View File
@@ -0,0 +1,152 @@
import FamilyControls
import Foundation
import PolicyCore
import SwiftUI
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var credentials: Credentials?
@Published var snapshot: Snapshot?
@Published var status: DeviceStatus?
@Published var challenge: MathChallenge?
@Published var answers: [String: String] = [:]
@Published var message = ""
@Published var rewardMessage = ""
@Published var busy = false
@Published var pickerPresented = false
@Published var draftSelection = FamilyActivitySelection()
private var setupUntil: Date?
init() {
do {
credentials = try KeychainStore.load()
if credentials != nil {
try StateStore.withState { state in
snapshot = state.snapshot; draftSelection = state.selection
}
}
} catch { message = error.localizedDescription }
}
private func api() throws -> APIClient {
guard let credentials else { throw LocalError.message("Pair this phone first") }
return try APIClient(server: credentials.server, token: credentials.token)
}
private func perform(_ operation: () async throws -> Void) async {
guard !busy else { return }
busy = true; message = ""
defer { busy = false }
do { try await operation() } catch { message = error.localizedDescription }
}
func pair(server: String, code: String) async {
await perform {
// Once enrolled, keep the server anchored in Keychain. A child's replacement server
// must not become a way to authorize new selection rules.
let endpoint = credentials?.server ?? server
let client = try APIClient(server: endpoint)
let body = try WireCoding.encoder().encode(["code": code.trimmingCharacters(in: .whitespacesAndNewlines),
"name": "Child iPhone"])
let response: PairResponse = try await client.request("/v1/pair", method: "POST", body: body)
let value = Credentials(server: client.origin.absoluteString, token: response.token,
deviceId: response.deviceId, childId: response.childId)
try KeychainStore.save(value); credentials = value
try await AuthorizationCenter.shared.requestAuthorization(for: .child)
try await synchronizeInternal()
setupUntil = Date().addingTimeInterval(300)
pickerPresented = true
}
}
func authorize() async {
await perform {
try await AuthorizationCenter.shared.requestAuthorization(for: .child)
try await synchronizeInternal()
}
}
func synchronize() async {
guard credentials != nil else { return }
await perform { try await synchronizeInternal() }
}
private func synchronizeInternal(selection: FamilyActivitySelection? = nil) async throws {
let client = try api()
let incoming: Snapshot = try await client.request("/v1/device/snapshot")
guard incoming.deviceId == credentials?.deviceId, incoming.childId == credentials?.childId else {
throw LocalError.message("Snapshot does not belong to this pairing")
}
try incoming.validate()
snapshot = incoming
let report: DeviceStatus
if AuthorizationCenter.shared.authorizationStatus != .approved {
report = try Enforcement.report(kind: "not_authorized", detail: "Guardian must approve Family Controls")
} else {
do { report = try Enforcement.apply(snapshot: incoming, selection: selection) }
catch {
report = try Enforcement.report(kind: "error", detail: error.localizedDescription)
message = error.localizedDescription
}
}
status = report
let _: Acknowledgement = try await client.request("/v1/device/status", method: "POST",
body: WireCoding.encoder().encode(report))
}
func unlockConfiguration(code: String) async {
await perform {
let grant: ConfigurationGrant = try await api().request("/v1/device/configuration-unlock",
method: "POST", body: WireCoding.encoder().encode(["code": code.trimmingCharacters(in: .whitespacesAndNewlines)]))
guard grant.authorized else { throw LocalError.message("Parent approval was not granted") }
setupUntil = Date().addingTimeInterval(300)
draftSelection = try StateStore.withState { $0.selection }
pickerPresented = true
}
}
func saveSelection() async {
await perform {
guard let setupUntil, setupUntil > Date() else {
throw LocalError.message("Parent setup expired; request a fresh configuration code")
}
guard !draftSelection.applicationTokens.isEmpty, draftSelection.categoryTokens.isEmpty,
draftSelection.webDomainTokens.isEmpty else {
throw LocalError.message("Choose individual apps, not whole categories or websites")
}
try await synchronizeInternal(selection: draftSelection)
self.setupUntil = nil
pickerPresented = false
}
}
func closeParentSetup() {
setupUntil = nil
pickerPresented = false
}
func startChallenge() async {
await perform {
challenge = try await api().request("/v1/device/challenges", method: "POST")
answers = [:]; rewardMessage = ""
}
}
func submitChallenge() async {
await perform {
guard let challenge else { return }
let submitted = try challenge.questions.map { question in
guard let answer = Int(answers[question.id, default: ""].trimmingCharacters(in: .whitespaces)) else {
throw LocalError.message("Enter an answer for every question")
}
return SubmittedAnswer(questionId: question.id, answer: answer)
}
let result: ChallengeResult = try await api().request("/v1/device/challenges/\(challenge.id)/submit",
method: "POST", body: WireCoding.encoder().encode(Submission(answers: submitted)))
self.challenge = nil
rewardMessage = result.passed
? "\(result.awardedMinutes) bonus minutes recorded for today."
: "\(result.correctCount) of \(result.requiredCount) correct. No time added. Try a new challenge."
try await synchronizeInternal()
}
}
}