The app is FTStore Admin. Its own AGENTS.md already sets the vocabulary — "the FTStore Admin server" for web/, "the FTStore Admin app" for this one — and then most of the comments in it said "the console" anyway. Three user-visible strings went with it: the lock screen, the menu item that locks it, and the biometric prompt. The storage-notification footer named the console too. Their catalog keys move with them, in every locale the app ships. Not "the app": inside these files that already means FTStore on somebody's phone, so a blind swap produced lines like "Dashboard and app only, never the app". AdminConsoleView is AdminHomeView. The Console/ directory keeps its name: the history of Console/*.swift runs back through manager/FTStore/Admin/ and is documented as doing so, which is worth more than a path nobody reads as a name. Also fills a translation gap this turned up — the status-change footer in the feedback detail was untranslated.
89 lines
3.2 KiB
Swift
89 lines
3.2 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|