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