153 lines
6.6 KiB
Swift
153 lines
6.6 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|