Files
lanework/Kanban/App/RestoreBootstrapView.swift
T
rzen 566deab506 Home app-side state in the shared App Group container
Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):

- AppGroup namespace: container resolution with per-edition fallback
  when unprovisioned, shared UserDefaults suite, edition identity, and
  a unit-test-host redirect (the test host IS the app — its launch
  sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
  openNow keyed by bundle id; hand-written Codable keeps legacy keys
  decoding (adopted in memory as the running edition's slots, upgraded
  on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
  first click runs an open panel pre-anchored at the recorded path,
  prompt "Grant"; recordOpen mints this edition's slot onto the
  matched shared record (path fallback only after identity fails and
  only against records holding no grant of ours, so re-granting never
  forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
  registry when the sibling edition wrote it, so one edition's save
  never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
  popover gains BoardEditionPresence ("Also open in Lanework Pro"),
  pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
  claims doomed trees by atomic rename into .sweeping/ then deletes,
  so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
  re-ruling; scalars (quick-style recents, window size) move to the
  shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
  family group). No pathfinder 1.x migrator: 1.x predates the
  registry; state starts fresh in the group container.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 20:18:15 -04:00

141 lines
7.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import os
/// The launch-time restoration pass, wearing a window because that is the only place SwiftUI lets
/// work like this run.
///
/// ### Why a window at all
///
/// Restoration has to open windows, and opening a window needs `openWindow`, which is only readable
/// from a view. An `App.init()` cannot do it and `AppDelegate` has no environment. So the app
/// presents one throwaway window at launch — 1×1, plain, ordered straight back out, absent from the
/// Window menu — whose only job is to run the pass and then dismiss itself. It exists for a few
/// hundred milliseconds and never draws.
///
/// It is presented **only** when there is something to open (`KanbanApp` decides, via
/// `LaunchPlan.presentsBootstrap`), so the ordinary launch-to-welcome path never creates it.
///
/// ### What the pass does
///
/// Reads the registry's flagged records in `lastOpened` order (`BoardRegistry.restorables()`), opens
/// the available ones, and records the unavailable ones as failures — 02 § Launch and window
/// lifecycle: "Other restorations proceed unaffected — never a launch-time modal chain, never a
/// silent drop." Welcome comes up only if nothing was even attempted; a board that *was* attempted
/// and then failed to load opens welcome from its own host, which is the same rule applied one layer
/// down and keeps this pass from having to wait on loads it did not perform.
///
/// ### And one other pass, for the same reason
///
/// The accessibility audit suite's fixture board (`UITestLaunch`) is built and opened here too. It is
/// the same job with a different source — filesystem work that must happen before the first real
/// window, needing `openWindow` to finish — and giving it a second throwaway window would be a second
/// copy of everything this file explains. Which pass runs is `plan`'s to say and nothing else's.
struct RestoreBootstrapView: View {
/// Decided in `KanbanApp.init()`; this view only dispatches on it.
let plan: LaunchPlan
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
@State private var windowController = HostedWindowController()
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "launch")
var body: some View {
Color.clear
.frame(width: 1, height: 1)
.background(WindowAccessor(controller: windowController))
.onAppear {
// Out of sight before it can be seen. `orderOut` rather than a hidden style because
// the scene must still exist — a window SwiftUI never presents never runs its task.
windowController.onAttach = { window in
window.alphaValue = 0
window.orderOut(nil)
}
if let window = windowController.window {
windowController.onAttach?(window)
}
}
.task { await restore() }
}
private func restore() async {
// Captured directly rather than waiting for `CaptureOpenWindow`'s `onAppear`: this task is
// the app's first act, and `openBoard` needs the action now.
appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
switch plan {
case .uiTestFixture:
openFixtureBoard()
case .restoreBoards, .welcome:
// `.welcome` never presents this window, so it cannot arrive here — and if a future
// launch path let it, the restoration pass is the harmless answer: it finds nothing
// flagged and shows welcome, which is what `.welcome` asked for anyway.
restoreFlaggedBoards()
}
dismissWindow(id: WindowID.restoreBootstrap)
}
private func restoreFlaggedBoards() {
var attempted = 0
for board in appModel.boardRegistry.restorables() {
switch board {
case let .available(_, url):
appModel.openBoard(at: url)
attempted += 1
case let .unavailable(record):
Self.logger.error("a flagged board could not be restored — its bookmark no longer resolves")
appModel.recordLaunchFailure(
path: record.lastKnownPath,
message: "This board is unavailable. Its volume may be offline, or it may have been moved or deleted."
)
case .needsReopen:
// Effectively unreachable — this edition can only have flagged a board open by having
// opened it, which needed a grant — and deliberately quiet if it ever happens.
//
// **No launch failure and no panel.** A modal grant panel at launch is exactly the
// "launch-time modal chain" 02 § Launch and window lifecycle rules out, and a failure
// row would put fail-fast's warning tone over a board that is *fine*: welcome appears
// (nothing restored), and this board's own row already carries the re-grant caption
// and the one click that resolves it (12-editions.md ▸ Distribution). That row is the
// surface, so nothing is silently dropped.
Self.logger.error("a flagged board is awaiting this edition's grant; left for its welcome row")
}
}
if attempted == 0 {
appModel.showWelcome()
}
}
/// The UI suites' board: built here, opened through the same `openBoard` every other path uses,
/// so it registers, bookmarks and titles itself exactly like a board the user opened.
///
/// **Which board is the launch arguments' to say** (`UITestLaunch.variant`), and this method does
/// not care: the malformed variant is built and opened exactly like the other two, and its
/// failure arrives one layer down as the *loader's* — a board window that records fail-fast's own
/// sentence and dismisses itself (`BoardWindowHost.start`). Special-casing it here would replace
/// the sentence under test with a sentence about the fixture.
///
/// **A failure to *build* lands on welcome as an ordinary launch failure**, with the fixture's own
/// path on it. That is deliberate: a suite whose fixture failed to build would otherwise audit an
/// empty screen and pass, which is the one outcome an accessibility gate must never produce.
private func openFixtureBoard() {
let variant = UITestLaunch.variant
do {
let url = try UITestLaunch.materializeFixtureBoard(variant)
appModel.openBoard(at: url)
} catch {
Self.logger.error("the UI-test fixture board could not be built: \(error.localizedDescription, privacy: .public)")
appModel.recordLaunchFailure(
path: UITestLaunch.fixtureBoardURL(for: variant).path,
message: "The UI-test fixture board could not be built: \(error.localizedDescription)"
)
appModel.showWelcome()
}
}
}