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)") }
}
}
+4
View File
@@ -0,0 +1,4 @@
APC_BUNDLE_PREFIX = de.felixfoertsch.parentalcontrols
APC_APP_GROUP = group.$(APC_BUNDLE_PREFIX)
APC_DEVELOPMENT_TEAM =
#include? "Local.xcconfig"
+6
View File
@@ -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>
+4
View File
@@ -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)
+26
View File
@@ -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)")
}
}
}
+123
View File
@@ -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)
}
}
+53
View File
@@ -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
View File
@@ -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