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() } } }