Files
Felitendo e0b2692e83 docs(admin): call it FTStore Admin, not "the console"
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.
2026-08-10 17:54:46 +02:00

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)
}
}