Disown AppKit window restoration — launches present welcome again instead of a windowless shell
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
This commit is contained in:
@@ -310,16 +310,22 @@ public final class AppModel {
|
|||||||
|
|
||||||
/// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
|
/// What `CaptureOpenWindow` calls. A method rather than two assignments so the launch flow, which
|
||||||
/// needs the actions before any `onAppear` has run, has one thing to call.
|
/// needs the actions before any `onAppear` has run, has one thing to call.
|
||||||
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) {
|
///
|
||||||
|
/// Returns how many buffered Finder-open URLs it replayed — the restore bootstrap's input: a
|
||||||
|
/// launch that already opened a document's board must not put welcome up beside it, and only this
|
||||||
|
/// method knows the buffer wasn't empty.
|
||||||
|
@discardableResult
|
||||||
|
func captureWindowActions(open: OpenWindowAction, dismiss: DismissWindowAction) -> Int {
|
||||||
windowOpener = open
|
windowOpener = open
|
||||||
windowDismisser = dismiss
|
windowDismisser = dismiss
|
||||||
|
|
||||||
guard !pendingOpenURLs.isEmpty else { return }
|
guard !pendingOpenURLs.isEmpty else { return 0 }
|
||||||
let urls = pendingOpenURLs
|
let urls = pendingOpenURLs
|
||||||
pendingOpenURLs.removeAll()
|
pendingOpenURLs.removeAll()
|
||||||
for url in urls {
|
for url in urls {
|
||||||
openBoard(at: url)
|
openBoard(at: url)
|
||||||
}
|
}
|
||||||
|
return urls.count
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Recents
|
// MARK: Recents
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import os
|
|||||||
/// Window menu — whose only job is to run the pass and then dismiss itself. It exists for a few
|
/// Window menu — whose only job is to run the pass and then dismiss itself. It exists for a few
|
||||||
/// hundred milliseconds and never draws.
|
/// hundred milliseconds and never draws.
|
||||||
///
|
///
|
||||||
/// It is presented **only** when there is something to open (`KanbanApp` decides, via
|
/// It is presented at **every** launch — it is the app's one reliable presenter (see `KanbanApp`'s
|
||||||
/// `LaunchPlan.presentsBootstrap`), so the ordinary launch-to-welcome path never creates it.
|
/// 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
|
/// ### What the pass does
|
||||||
///
|
///
|
||||||
@@ -63,24 +64,27 @@ struct RestoreBootstrapView: View {
|
|||||||
|
|
||||||
private func restore() async {
|
private func restore() async {
|
||||||
// Captured directly rather than waiting for `CaptureOpenWindow`'s `onAppear`: this task is
|
// Captured directly rather than waiting for `CaptureOpenWindow`'s `onAppear`: this task is
|
||||||
// the app's first act, and `openBoard` needs the action now.
|
// the app's first act, and `openBoard` needs the action now. The count is a cold Finder-open
|
||||||
appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
|
// 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 {
|
switch plan {
|
||||||
case .uiTestFixture:
|
case .uiTestFixture:
|
||||||
openFixtureBoard()
|
openFixtureBoard()
|
||||||
case .restoreBoards, .welcome:
|
case .restoreBoards, .welcome:
|
||||||
// `.welcome` never presents this window, so it cannot arrive here — and if a future
|
// `.welcome` arrives here by design — this window presents at every launch, because it is
|
||||||
// launch path let it, the restoration pass is the harmless answer: it finds nothing
|
// the app's one reliable presenter (see `KanbanApp`'s bootstrap scene) — and the pass is
|
||||||
// flagged and shows welcome, which is what `.welcome` asked for anyway.
|
// its answer: nothing is flagged for this edition, so it shows welcome, which is what
|
||||||
restoreFlaggedBoards()
|
// `.welcome` asked for.
|
||||||
|
restoreFlaggedBoards(openedAlready: replayedOpens)
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissWindow(id: WindowID.restoreBootstrap)
|
dismissWindow(id: WindowID.restoreBootstrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreFlaggedBoards() {
|
private func restoreFlaggedBoards(openedAlready: Int) {
|
||||||
var attempted = 0
|
var attempted = openedAlready
|
||||||
for board in appModel.boardRegistry.restorables() {
|
for board in appModel.boardRegistry.restorables() {
|
||||||
switch board {
|
switch board {
|
||||||
case let .available(_, url):
|
case let .available(_, url):
|
||||||
|
|||||||
@@ -42,12 +42,6 @@ enum LaunchPlan: Equatable, Sendable {
|
|||||||
) ? .restoreBoards : .welcome
|
) ? .restoreBoards : .welcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the throwaway bootstrap window is presented at launch — everything except the plain
|
|
||||||
/// welcome case, since both other plans have to open windows and only a view can do that
|
|
||||||
/// (`RestoreBootstrapView`'s own reason for wearing a window).
|
|
||||||
var presentsBootstrap: Bool {
|
|
||||||
self != .welcome
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITestLaunch
|
// MARK: - UITestLaunch
|
||||||
|
|||||||
+26
-2
@@ -41,6 +41,17 @@ struct KanbanApp: App {
|
|||||||
private let launchPlan: LaunchPlan
|
private let launchPlan: LaunchPlan
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// **AppKit window restoration is fully disowned** — restore-at-launch is the registry's job
|
||||||
|
// (02-architecture.md § Launch and window lifecycle), every scene below declares
|
||||||
|
// `.restorationBehavior(.disabled)`, and left alive the machinery is actively harmful: AppKit
|
||||||
|
// counts its saved state (even a windowless one) as "a restored session", and SwiftUI then
|
||||||
|
// treats every `defaultLaunchBehavior` as moot — the app launches with no windows at all and
|
||||||
|
// no way to get one, since `windowOpener` is captured by the first scene that appears
|
||||||
|
// (observed on macOS 26, 2026-07-29; `-ApplePersistenceIgnoreState YES` on the command line
|
||||||
|
// proved the mechanism). Registered here because `App.init` runs before `NSApplicationMain`,
|
||||||
|
// which is what makes a registration-domain default early enough for AppKit's read.
|
||||||
|
UserDefaults.standard.register(defaults: ["ApplePersistenceIgnoreState": true])
|
||||||
|
|
||||||
// Read first, because it decides *which app-side state the model is built over* — a fixture
|
// Read first, because it decides *which app-side state the model is built over* — a fixture
|
||||||
// launch keeps its recents and its clipboard snapshots in the scratch directory rather than in
|
// launch keeps its recents and its clipboard snapshots in the scratch directory rather than in
|
||||||
// the shared App Group container.
|
// the shared App Group container.
|
||||||
@@ -73,7 +84,12 @@ struct KanbanApp: App {
|
|||||||
.environment(appModel)
|
.environment(appModel)
|
||||||
.captureWindowActions(into: appModel)
|
.captureWindowActions(into: appModel)
|
||||||
}
|
}
|
||||||
.defaultLaunchBehavior(launchPlan == .welcome ? .automatic : .suppressed)
|
// **Never presented by the system** — the restore bootstrap below opens welcome through
|
||||||
|
// `AppModel.showWelcome()` when the launch pass ends with nothing else on screen. `.automatic`
|
||||||
|
// was tried here (conditioned on the plan) and macOS 26 answered it by presenting *no scene at
|
||||||
|
// all*: not welcome, not even a `.presented` bootstrap — a windowless launch with no way back,
|
||||||
|
// since `windowOpener` is captured by the first scene that appears. One presenter, one rule.
|
||||||
|
.defaultLaunchBehavior(.suppressed)
|
||||||
.restorationBehavior(.disabled)
|
.restorationBehavior(.disabled)
|
||||||
// "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen &
|
// "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen &
|
||||||
// templates). `.contentMinSize` rather than `.contentSize`, because the view states a
|
// templates). `.contentMinSize` rather than `.contentSize`, because the view states a
|
||||||
@@ -104,7 +120,15 @@ struct KanbanApp: App {
|
|||||||
.environment(appModel)
|
.environment(appModel)
|
||||||
.captureWindowActions(into: appModel)
|
.captureWindowActions(into: appModel)
|
||||||
}
|
}
|
||||||
.defaultLaunchBehavior(launchPlan.presentsBootstrap ? .presented : .suppressed)
|
// **Presented at every launch, whatever the plan** — the bootstrap is the app's one reliable
|
||||||
|
// way to put a window on screen. Welcome's `.automatic` above is a request the system is free
|
||||||
|
// to decline, and on macOS 26 it does: a launch with nothing to restore presented *no* scene
|
||||||
|
// at all, which left `windowOpener` uncaptured and the app a windowless shell no menu action
|
||||||
|
// could revive (observed 2026-07-29; the per-edition open-now flags exposed it, because before
|
||||||
|
// them a flagged board almost always routed launches through this window). The pass itself
|
||||||
|
// still dispatches on the plan — a `.welcome` launch restores nothing and shows welcome —
|
||||||
|
// and this window stays invisible and dismisses itself either way.
|
||||||
|
.defaultLaunchBehavior(.presented)
|
||||||
.restorationBehavior(.disabled)
|
.restorationBehavior(.disabled)
|
||||||
.windowStyle(.plain)
|
.windowStyle(.plain)
|
||||||
.defaultSize(width: 1, height: 1)
|
.defaultSize(width: 1, height: 1)
|
||||||
|
|||||||
@@ -48,14 +48,6 @@ struct LaunchPlanTests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which plans need the throwaway bootstrap window: both the ones that open something, neither
|
|
||||||
/// more. Welcome is a scene the app presents directly and needs no view to run a pass for it.
|
|
||||||
@Test("Only the two opening plans present the bootstrap window")
|
|
||||||
func bootstrapPresentation() {
|
|
||||||
#expect(LaunchPlan.welcome.presentsBootstrap == false)
|
|
||||||
#expect(LaunchPlan.restoreBoards.presentsBootstrap)
|
|
||||||
#expect(LaunchPlan.uiTestFixture.presentsBootstrap)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The flag
|
// MARK: - The flag
|
||||||
|
|||||||
Reference in New Issue
Block a user