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
+74
View File
@@ -0,0 +1,74 @@
import Foundation
import PolicyCore
struct Credentials: Codable {
var server: String
var token: String
var deviceId: String
var childId: String
}
struct PairResponse: Decodable { let deviceId: String; let childId: String; let token: String }
struct Acknowledgement: Decodable { let received: Bool }
struct ConfigurationGrant: Decodable { let authorized: Bool }
struct MathChallenge: Decodable, Identifiable {
struct Question: Decodable, Identifiable { let id: String; let prompt: String }
let id: String
let expiresAt: Double
let questions: [Question]
}
struct SubmittedAnswer: Encodable { let questionId: String; let answer: Int }
struct Submission: Encodable { let answers: [SubmittedAnswer] }
struct ChallengeResult: Decodable {
let passed: Bool
let correctCount: Int
let requiredCount: Int
let awardedMinutes: Int
}
private struct APIError: Decodable { let detail: String }
private final class NoRedirects: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
struct APIClient {
let origin: URL
let token: String?
init(server: String, token: String? = nil) throws {
guard let parts = URLComponents(string: server.trimmingCharacters(in: .whitespacesAndNewlines)),
let host = parts.host, parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil, ["", "/"].contains(parts.path),
let url = parts.url else { throw LocalError.message("Enter the server origin, without a path") }
var allowed = parts.scheme == "https"
#if DEBUG
allowed = allowed || (parts.scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host))
#endif
guard allowed else { throw LocalError.message("Use HTTPS with a trusted certificate; plain LAN HTTP is not supported") }
origin = url
self.token = token
}
func request<T: Decodable>(_ path: String, method: String = "GET", body: Data? = nil) async throws -> T {
guard let url = URL(string: path, relativeTo: origin)?.absoluteURL,
url.host == origin.host else { throw LocalError.message("Invalid API path") }
var request = URLRequest(url: url)
request.httpMethod = method; request.httpBody = body; request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
let config = URLSessionConfiguration.ephemeral
config.httpShouldSetCookies = false; config.urlCache = nil
let session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw LocalError.message("Invalid server response") }
guard (200...299).contains(http.statusCode) else {
let detail = (try? JSONDecoder().decode(APIError.self, from: data).detail) ?? "HTTP \(http.statusCode)"
throw LocalError.message(detail)
}
return try WireCoding.decoder().decode(T.self, from: data)
}
}
+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()
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import FamilyControls
import SwiftUI
@main
struct FamilyQuestsApp: App {
@StateObject private var model = AppModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView(model: model)
.task { await model.synchronize() }
.onChange(of: scenePhase) { _, phase in
if phase == .active { Task { await model.synchronize() } }
if phase == .background { model.closeParentSetup() }
}
}
}
}
struct RootView: View {
@ObservedObject var model: AppModel
@State private var server = ""
@State private var code = ""
@State private var configurationCode = ""
@State private var replacementCode = ""
var body: some View {
NavigationStack {
Form {
if model.credentials == nil {
Section("Parent: pair this iPhone") {
Text("Create a child and a pairing code in the parent web dashboard. A guardian must approve Apple Family Controls.")
TextField("https://your-server.example", text: $server)
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
SecureField("Single-use pairing code", text: $code)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Pair and request guardian approval") {
let submitted = code; code = ""
Task { await model.pair(server: server, code: submitted) }
}
}
} else {
Section(model.snapshot?.childName ?? "My time") {
if let snapshot = model.snapshot {
LabeledContent("Daily allowance", value: "\(snapshot.policy.dailyMinutes) minutes")
LabeledContent("Bonus earned today", value: "\(snapshot.bonus(at: Date())) minutes")
Text("This is your allowance, not a live remaining-time counter. Rewards expire at the end of the family day and never extend bedtime.")
.font(.footnote).foregroundStyle(.secondary)
}
Text(model.status?.detail ?? "Open or sync the app to check configuration")
Button("Sync policy and rewards") { Task { await model.synchronize() } }
}
Section("Maths challenge") {
if let challenge = model.challenge {
ForEach(challenge.questions) { question in
HStack {
Text("\(question.prompt) =")
TextField("Answer", text: Binding(
get: { model.answers[question.id, default: ""] },
set: { model.answers[question.id] = $0 }
)).keyboardType(.numberPad).multilineTextAlignment(.trailing)
.accessibilityLabel("Answer to \(question.prompt)")
}
}
Button("Check answers") { Task { await model.submitChallenge() } }
} else {
Text("Answer every question correctly to earn bonus app usage. An internet connection is required to verify answers.")
Button("Start challenge") { Task { await model.startChallenge() } }
.disabled(model.status?.state != "monitoring")
}
if !model.rewardMessage.isEmpty { Text(model.rewardMessage) }
}
Section("Parent setup") {
Text("App selection requires a fresh configuration code from the parent dashboard. Do not restrict this app, Phone, or other essential communication and school apps.")
.font(.footnote)
Button("Request guardian Screen Time authorization") { Task { await model.authorize() } }
SecureField("Configuration code", text: $configurationCode)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Authorize app selection") {
let value = configurationCode; configurationCode = ""
Task { await model.unlockConfiguration(code: value) }
}
DisclosureGroup("Replace revoked pairing") {
Text("The parent must revoke the old pairing and issue a new pairing code. The server address remains fixed.")
SecureField("New pairing code", text: $replacementCode)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Pair again with the same server") {
let value = replacementCode; replacementCode = ""
Task { await model.pair(server: "", code: value) }
}
}
}
}
if !model.message.isEmpty {
Section("Needs attention") { Text(model.message).foregroundStyle(.red) }
}
if model.busy { ProgressView("Working…") }
Section { Text("Development MVP. DeviceActivity behavior must be tested on this iOS version before relying on it.").font(.footnote) }
}
.disabled(model.busy)
.navigationTitle("Family Quests")
.sheet(isPresented: $model.pickerPresented, onDismiss: { model.closeParentSetup() }) {
NavigationStack {
VStack {
Text("Choose individual entertainment apps only. Leave Family Quests and essential apps accessible.")
.font(.footnote).padding()
FamilyActivityPicker(selection: $model.draftSelection)
}
.navigationTitle("Parent app selection")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { model.closeParentSetup() } }
ToolbarItem(placement: .confirmationAction) {
Button("Save") { Task { await model.saveSelection() } }.disabled(model.busy)
}
}
.safeAreaInset(edge: .bottom) {
if !model.message.isEmpty { Text(model.message).font(.footnote).padding() }
}
}
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import Foundation
import PolicyCore
import Security
enum KeychainStore {
private static var query: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Bundle.main.bundleIdentifier ?? "APC",
kSecAttrAccount as String: "paired-device"]
}
static func load() throws -> Credentials? {
var request = query
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(request as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = item as? Data else {
throw LocalError.message("Cannot read pairing credentials: \(status)")
}
return try WireCoding.decoder().decode(Credentials.self, from: data)
}
static func save(_ credentials: Credentials) throws {
let data = try WireCoding.encoder().encode(credentials)
let update = [kSecValueData as String: data]
let result = SecItemUpdate(query as CFDictionary, update as CFDictionary)
if result == errSecItemNotFound {
var insert = query
insert[kSecValueData as String] = data
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(insert as CFDictionary, nil)
guard status == errSecSuccess else { throw LocalError.message("Cannot save pairing: \(status)") }
} else if result != errSecSuccess { throw LocalError.message("Cannot update pairing: \(result)") }
}
}