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)") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
APC_BUNDLE_PREFIX = de.felixfoertsch.parentalcontrols
|
||||
APC_APP_GROUP = group.$(APC_BUNDLE_PREFIX)
|
||||
APC_DEVELOPMENT_TEAM =
|
||||
#include? "Local.xcconfig"
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.developer.family-controls</key><true/>
|
||||
<key>com.apple.security.application-groups</key><array><string>$(APC_APP_GROUP)</string></array>
|
||||
</dict></plist>
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copy to Local.xcconfig (ignored by git). Supply your own identifiers and signing.
|
||||
APC_DEVELOPMENT_TEAM = YOURTEAMID
|
||||
APC_BUNDLE_PREFIX = de.felixfoertsch.parentalcontrols
|
||||
APC_APP_GROUP = group.$(APC_BUNDLE_PREFIX)
|
||||
@@ -0,0 +1,26 @@
|
||||
import DeviceActivity
|
||||
import OSLog
|
||||
|
||||
final class ActivityMonitor: DeviceActivityMonitor {
|
||||
private let logger = Logger(subsystem: "APC", category: "DeviceActivity")
|
||||
override func intervalDidStart(for activity: DeviceActivityName) {
|
||||
super.intervalDidStart(for: activity)
|
||||
handle(activity)
|
||||
}
|
||||
override func intervalDidEnd(for activity: DeviceActivityName) {
|
||||
super.intervalDidEnd(for: activity)
|
||||
handle(activity, ended: true)
|
||||
}
|
||||
override func eventDidReachThreshold(_ event: DeviceActivityEvent.Name, activity: DeviceActivityName) {
|
||||
super.eventDidReachThreshold(event, activity: activity)
|
||||
handle(activity, event: event)
|
||||
}
|
||||
private func handle(_ activity: DeviceActivityName, event: DeviceActivityEvent.Name? = nil,
|
||||
ended: Bool = false) {
|
||||
do { try Enforcement.callback(activity: activity, event: event, ended: ended) }
|
||||
catch {
|
||||
// Never clear existing restrictions because storage is temporarily unavailable.
|
||||
logger.error("Screen Time callback could not reconcile state: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import CryptoKit
|
||||
import DeviceActivity
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
import ManagedSettings
|
||||
import PolicyCore
|
||||
|
||||
/// Native adapter. PolicyCore is unit-tested; Apple callback delivery still needs device tests.
|
||||
enum Enforcement {
|
||||
private static let prefix = "apc.window."
|
||||
private static var settings: ManagedSettingsStore {
|
||||
ManagedSettingsStore(named: ManagedSettingsStore.Name("apc.entertainment"))
|
||||
}
|
||||
|
||||
private static func shield(_ selection: FamilyActivitySelection, enabled: Bool) {
|
||||
settings.shield.applications = enabled ? selection.applicationTokens : nil
|
||||
}
|
||||
|
||||
static func apply(snapshot: Snapshot? = nil,
|
||||
selection: FamilyActivitySelection? = nil) throws -> DeviceStatus {
|
||||
try StateStore.withState { state in
|
||||
if let snapshot {
|
||||
try snapshot.validate()
|
||||
guard abs(Date().timeIntervalSince1970 - snapshot.serverTime) < 300 else {
|
||||
throw LocalError.message("Phone/server clocks differ by more than five minutes")
|
||||
}
|
||||
state.snapshot = snapshot
|
||||
}
|
||||
if let selection {
|
||||
guard !selection.applicationTokens.isEmpty,
|
||||
selection.categoryTokens.isEmpty, selection.webDomainTokens.isEmpty else {
|
||||
throw LocalError.message("Select individual apps only; whole categories and websites are deferred in v0")
|
||||
}
|
||||
state.selection = selection
|
||||
state.selectionRevision = UUID().uuidString
|
||||
}
|
||||
guard let snapshot = state.snapshot, !state.selection.applicationTokens.isEmpty else {
|
||||
return status(state, kind: "not_configured", detail: "Pair and select individual apps with a guardian")
|
||||
}
|
||||
try snapshot.validate()
|
||||
let now = Date()
|
||||
let policy = snapshot.policy
|
||||
state.observation.roll(to: policy.day(at: now))
|
||||
let keyData = try WireCoding.encoder().encode(policy) + Data(state.selectionRevision.utf8)
|
||||
let key = SHA256.hash(data: keyData).map { String(format: "%02x", $0) }.joined()
|
||||
let budget = policy.dailyMinutes + snapshot.bonus(at: now)
|
||||
let center = DeviceActivityCenter()
|
||||
let activity = state.generation.map { DeviceActivityName(prefix + $0) }
|
||||
let registered = activity.map { center.activities.contains($0) } ?? false
|
||||
let needsPlan = state.policyKey != key || !state.monitoringReady || !registered
|
||||
|| (budget > 0 && !state.thresholds.contains(budget))
|
||||
if needsPlan {
|
||||
// Put the shield on before changing the monitor. A failed registration stays blocked.
|
||||
shield(state.selection, enabled: true)
|
||||
state.shielded = true
|
||||
state.monitoringReady = false
|
||||
state.generation = UUID().uuidString
|
||||
state.thresholds = policy.thresholds(startingBonus: snapshot.bonus(at: now))
|
||||
let name = DeviceActivityName(prefix + state.generation!)
|
||||
var start = DateComponents(hour: policy.allowedStart / 60, minute: policy.allowedStart % 60)
|
||||
var end = DateComponents(hour: policy.allowedEnd / 60, minute: policy.allowedEnd % 60)
|
||||
start.timeZone = policy.calendar.timeZone; end.timeZone = policy.calendar.timeZone
|
||||
let schedule = DeviceActivitySchedule(intervalStart: start, intervalEnd: end, repeats: true)
|
||||
let events = Dictionary(uniqueKeysWithValues: state.thresholds.map { minutes in
|
||||
(DeviceActivityEvent.Name("minutes.\(minutes)"), DeviceActivityEvent(
|
||||
applications: state.selection.applicationTokens,
|
||||
threshold: DateComponents(minute: minutes), includesPastActivity: true
|
||||
))
|
||||
})
|
||||
do {
|
||||
try center.startMonitoring(name, during: schedule, events: events)
|
||||
// Stop only our own obsolete schedules, after the replacement exists.
|
||||
center.stopMonitoring(center.activities.filter { $0.rawValue.hasPrefix(prefix) && $0 != name })
|
||||
state.monitoringReady = true
|
||||
state.monitoringError = nil
|
||||
state.policyKey = key
|
||||
state.lastEvent = "Monitoring registered; awaiting system callbacks"
|
||||
} catch {
|
||||
center.stopMonitoring(center.activities.filter { $0.rawValue.hasPrefix(prefix) })
|
||||
state.monitoringError = String(String(describing: error).prefix(300))
|
||||
}
|
||||
}
|
||||
let decision = Decision.evaluate(snapshot, observation: state.observation, now: now)
|
||||
state.shielded = !state.monitoringReady || decision.shielded
|
||||
shield(state.selection, enabled: state.shielded)
|
||||
return status(state, kind: state.monitoringReady ? "monitoring" : "error",
|
||||
detail: state.monitoringError ?? "\(decision.reason). \(state.lastEvent)")
|
||||
}
|
||||
}
|
||||
|
||||
static func callback(activity: DeviceActivityName, event: DeviceActivityEvent.Name? = nil,
|
||||
ended: Bool = false) throws {
|
||||
try StateStore.withState { state in
|
||||
guard let generation = state.generation, let snapshot = state.snapshot,
|
||||
activity.rawValue == prefix + generation else { return } // Ignore obsolete monitors.
|
||||
let now = Date()
|
||||
let day = snapshot.policy.day(at: now)
|
||||
state.observation.roll(to: day)
|
||||
if let event, event.rawValue.hasPrefix("minutes."),
|
||||
let minutes = Int(event.rawValue.dropFirst("minutes.".count)),
|
||||
state.thresholds.contains(minutes), snapshot.policy.isAllowed(at: now) {
|
||||
state.observation.record(threshold: minutes, day: day)
|
||||
state.lastEvent = "Reached \(minutes)-minute usage threshold"
|
||||
} else if event == nil {
|
||||
state.lastEvent = ended ? "Allowed window ended" : "Allowed window started"
|
||||
}
|
||||
let decision = Decision.evaluate(snapshot, observation: state.observation, now: now)
|
||||
state.shielded = ended || !state.monitoringReady || decision.shielded
|
||||
shield(state.selection, enabled: state.shielded)
|
||||
}
|
||||
}
|
||||
|
||||
static func report(kind: String, detail: String) throws -> DeviceStatus {
|
||||
try StateStore.withState { state in status(state, kind: kind, detail: detail) }
|
||||
}
|
||||
|
||||
private static func status(_ state: NativeState, kind: String, detail: String) -> DeviceStatus {
|
||||
DeviceStatus(policyRevision: state.snapshot?.policyRevision ?? 0, state: kind,
|
||||
shielded: state.shielded, detail: String(detail.prefix(500)),
|
||||
consumedLowerBound: state.observation.reachedMinutes,
|
||||
usageDay: state.observation.day)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Darwin
|
||||
import FamilyControls
|
||||
import Foundation
|
||||
import PolicyCore
|
||||
|
||||
struct NativeState: Codable {
|
||||
var snapshot: Snapshot?
|
||||
var selection = FamilyActivitySelection()
|
||||
var selectionRevision = UUID().uuidString
|
||||
var observation = Observation()
|
||||
var generation: String?
|
||||
var policyKey: String?
|
||||
var thresholds: [Int] = []
|
||||
var monitoringReady = false
|
||||
var shielded = false
|
||||
var lastEvent = "Not configured"
|
||||
var monitoringError: String?
|
||||
}
|
||||
|
||||
enum LocalError: LocalizedError {
|
||||
case message(String)
|
||||
var errorDescription: String? {
|
||||
switch self { case .message(let value): return value }
|
||||
}
|
||||
}
|
||||
|
||||
enum StateStore {
|
||||
/// A process-wide UserDefaults cache is not sufficient: the app and extension both write.
|
||||
/// Lock one file while atomically replacing another. Corrupt state is never reset to defaults.
|
||||
static func withState<T>(_ operation: (inout NativeState) throws -> T) throws -> T {
|
||||
guard let group = Bundle.main.object(forInfoDictionaryKey: "APCAppGroup") as? String,
|
||||
let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
|
||||
throw LocalError.message("App Group unavailable. Check signing and APC_APP_GROUP on all targets.")
|
||||
}
|
||||
let lockPath = directory.appendingPathComponent("state.lock").path
|
||||
let fd = open(lockPath, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
|
||||
guard fd >= 0 else { throw LocalError.message("Cannot open the shared-state lock") }
|
||||
defer { close(fd) }
|
||||
guard flock(fd, LOCK_EX) == 0 else { throw LocalError.message("Cannot lock shared state") }
|
||||
defer { flock(fd, LOCK_UN) }
|
||||
let file = directory.appendingPathComponent("state.json")
|
||||
var state = NativeState()
|
||||
if FileManager.default.fileExists(atPath: file.path) {
|
||||
state = try WireCoding.decoder().decode(NativeState.self, from: Data(contentsOf: file))
|
||||
}
|
||||
let result = try operation(&state)
|
||||
try WireCoding.encoder().encode(state).write(to: file, options: .atomic)
|
||||
try FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], atPath: file.path
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import ManagedSettings
|
||||
import ManagedSettingsUI
|
||||
import UIKit
|
||||
|
||||
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
||||
override func configuration(shielding application: Application) -> ShieldConfiguration {
|
||||
ShieldConfiguration(
|
||||
backgroundBlurStyle: .systemMaterial,
|
||||
backgroundColor: .systemBackground,
|
||||
icon: UIImage(systemName: "hourglass"),
|
||||
title: .init(text: "Time for a break", color: .label),
|
||||
subtitle: .init(text: "Your allowance is used, or it is outside your allowed hours. Open Family Quests to check your time or try a maths challenge. Rewards cannot extend bedtime.", color: .secondaryLabel),
|
||||
primaryButtonLabel: .init(text: "Close", color: .white),
|
||||
primaryButtonBackgroundColor: .systemIndigo
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import ManagedSettings
|
||||
|
||||
final class ShieldActionExtension: ShieldActionDelegate {
|
||||
override func handle(action: ShieldAction, for application: ApplicationToken,
|
||||
completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
||||
completionHandler(.close)
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
name: AdvancedParentalControls
|
||||
options:
|
||||
minimumXcodeGenVersion: 2.42.0
|
||||
deploymentTarget:
|
||||
iOS: '18.0'
|
||||
createIntermediateGroups: true
|
||||
configFiles:
|
||||
Debug: Config/Base.xcconfig
|
||||
Release: Config/Base.xcconfig
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: '5.0'
|
||||
SWIFT_STRICT_CONCURRENCY: targeted
|
||||
TARGETED_DEVICE_FAMILY: '1'
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: $(APC_DEVELOPMENT_TEAM)
|
||||
MARKETING_VERSION: 0.1.0
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
packages:
|
||||
PolicyCore:
|
||||
path: ../packages/PolicyCore
|
||||
targetTemplates:
|
||||
ScreenTimeExtension:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
settings:
|
||||
base:
|
||||
APPLICATION_EXTENSION_API_ONLY: YES
|
||||
CODE_SIGN_ENTITLEMENTS: Config/FamilyControls.entitlements
|
||||
dependencies:
|
||||
- package: PolicyCore
|
||||
targets:
|
||||
FamilyQuests:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources: [App, Shared]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app
|
||||
CODE_SIGN_ENTITLEMENTS: Config/FamilyControls.entitlements
|
||||
info:
|
||||
path: Generated/App-Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Family Quests
|
||||
APCAppGroup: $(APC_APP_GROUP)
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
NSLocalNetworkUsageDescription: Connect to your family's self-hosted policy server on the local network.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true
|
||||
dependencies:
|
||||
- package: PolicyCore
|
||||
- target: ActivityMonitor
|
||||
embed: true
|
||||
- target: ShieldConfiguration
|
||||
embed: true
|
||||
- target: ShieldAction
|
||||
embed: true
|
||||
ActivityMonitor:
|
||||
templates: [ScreenTimeExtension]
|
||||
sources: [Monitor, Shared]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app.monitor
|
||||
info:
|
||||
path: Generated/Monitor-Info.plist
|
||||
properties:
|
||||
APCAppGroup: $(APC_APP_GROUP)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.deviceactivity.monitor-extension
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ActivityMonitor
|
||||
ShieldConfiguration:
|
||||
templates: [ScreenTimeExtension]
|
||||
sources: [Shield]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app.shield
|
||||
info:
|
||||
path: Generated/Shield-Info.plist
|
||||
properties:
|
||||
APCAppGroup: $(APC_APP_GROUP)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.ManagedSettingsUI.shield-configuration-service
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShieldConfigurationExtension
|
||||
ShieldAction:
|
||||
templates: [ScreenTimeExtension]
|
||||
sources: [ShieldAction]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: $(APC_BUNDLE_PREFIX).app.shieldaction
|
||||
info:
|
||||
path: Generated/ShieldAction-Info.plist
|
||||
properties:
|
||||
APCAppGroup: $(APC_APP_GROUP)
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.ManagedSettings.shield-action-service
|
||||
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShieldActionExtension
|
||||
schemes:
|
||||
FamilyQuests:
|
||||
build:
|
||||
targets:
|
||||
FamilyQuests: all
|
||||
run:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
Reference in New Issue
Block a user