macOS keeps Saved Application State per app, and on macOS 26 its mere existence — even describing zero windows, which repeated dev kills guarantee — counts as "a restored session": SwiftUI then treats every scene's defaultLaunchBehavior as moot and presents nothing. The app launched as a windowless shell with no way back, since windowOpener is captured by the first scene that appears — so Open Recent, the re-grant Grant click, and Dock reopen all silently buffered or no-opped. Proven by -ApplePersistenceIgnoreState YES presenting correctly on the same state; with the fix, welcome presented 3/3 consecutive plain launches. Three changes: - App.init registers ApplePersistenceIgnoreState — restoration is the registry's job (02 § Launch and window lifecycle), every scene already declares restorationBehavior(.disabled), and AppKit's layer was pure liability. Registered before NSApplicationMain runs, which is what makes a registration-domain default early enough. - The restore bootstrap presents at every launch as the app's one reliable presenter; welcome is never system-presented (.suppressed) — the pass opens it when nothing else lands on screen. LaunchPlan.presentsBootstrap retired. - captureWindowActions returns the replayed Finder-open count so the pass counts those as opens — a cold document launch doesn't get welcome stacked beside its board. Both suites green, verify-editions 30/30. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
145 lines
7.5 KiB
Swift
145 lines
7.5 KiB
Swift
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 at **every** launch — it is the app's one reliable presenter (see `KanbanApp`'s
|
||
/// bootstrap scene for the macOS 26 behavior that forced this), so even the plain launch-to-welcome
|
||
/// path runs through it: the pass finds nothing flagged and opens welcome itself.
|
||
///
|
||
/// ### 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. The count is a cold Finder-open
|
||
// that arrived before this window did — a board already on its way to the screen, which the
|
||
// pass below must count as an open or it would put welcome up beside the user's document.
|
||
let replayedOpens = appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
|
||
|
||
switch plan {
|
||
case .uiTestFixture:
|
||
openFixtureBoard()
|
||
case .restoreBoards, .welcome:
|
||
// `.welcome` arrives here by design — this window presents at every launch, because it is
|
||
// the app's one reliable presenter (see `KanbanApp`'s bootstrap scene) — and the pass is
|
||
// its answer: nothing is flagged for this edition, so it shows welcome, which is what
|
||
// `.welcome` asked for.
|
||
restoreFlaggedBoards(openedAlready: replayedOpens)
|
||
}
|
||
|
||
dismissWindow(id: WindowID.restoreBootstrap)
|
||
}
|
||
|
||
private func restoreFlaggedBoards(openedAlready: Int) {
|
||
var attempted = openedAlready
|
||
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()
|
||
}
|
||
}
|
||
}
|