// // FTStoreAdminApp.swift // FTStore Admin // // FTStore Admin as its own app. It used to be a second tab inside // FTStore Manager, gated behind a hidden developer mode; splitting it out means // someone who only wants to install FTStore never sees an operator surface at // all, and this app never has to carry the Manager's signing/install engine. // // It ships no credential of any kind — the only baked-in value is the server's // base URL (see `AdminConfig`), and every screen sits behind `AdminSession`, // which is behind a server login the operator can revoke. // import BackgroundTasks import SwiftUI @main struct FTStoreAdminApp: App { @StateObject private var session = AdminSession.shared @Environment(\.scenePhase) private var scenePhase /// The background refresh that lets FTStore Admin say anything while it is /// closed. Declared in `Info.plist` under the same string. private static let alertTaskId = "app.ftstore.admin.alerts" init() { Self._registerAlertTask() } var body: some Scene { WindowGroup { AdminRootView() .environmentObject(session) .tint(AdminTheme.brand) // Check on the way in as well as in the background. Somebody who // opens FTStore Admin after a day away should not have to wait for // iOS to decide it is time. .task { await AdminAlertWatcher.shared.check(session: session) } // Re-arm the optional biometric gate whenever FTStore Admin leaves // the screen: an unattended device is exactly the case it exists // for. `.background` rather than `.inactive`, so the Face ID sheet // and the share sheet don't lock FTStore Admin behind themselves. .onChange(of: scenePhase) { phase in guard phase == .background else { return } // Ask for a refresh on the way out, which is the only moment // iOS accepts one. Locking follows, since the lock is about // the screen and the refresh is not. Self._scheduleAlertTask() guard session.requireBiometrics else { return } session.lock() } } } // MARK: - Background refresh /// Register before the app finishes launching, which `init` guarantees and a /// `.task` does not: iOS rejects a handler registered any later. private static func _registerAlertTask() { BGTaskScheduler.shared.register( forTaskWithIdentifier: alertTaskId, using: nil ) { task in // Queue the next one first. A run that returned without rescheduling // would be the last one this install ever gets. _scheduleAlertTask() let work = Task { @MainActor in let posted = await AdminAlertWatcher.shared.check() task.setTaskCompleted(success: posted) } // iOS reclaims the time whenever it likes; without this the task is // killed rather than ended, which counts against future scheduling. task.expirationHandler = { work.cancel() } } } private static func _scheduleAlertTask() { guard AdminAlertWatcher.shared.anyEnabled else { return } let request = BGAppRefreshTaskRequest(identifier: alertTaskId) // A floor, not a promise. The system decides the real cadence from how // often FTStore Admin is opened and what the battery is doing. request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) try? BGTaskScheduler.shared.submit(request) } }