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