From 90954a47d60e70cf07f4fd6dfe98f555d27e8e92 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 22:48:04 -0400 Subject: [PATCH] =?UTF-8?q?Disown=20AppKit=20window=20restoration=20?= =?UTF-8?q?=E2=80=94=20launches=20present=20welcome=20again=20instead=20of?= =?UTF-8?q?=20a=20windowless=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppModel.swift | 10 ++++++++-- Kanban/App/RestoreBootstrapView.swift | 24 +++++++++++++---------- Kanban/App/UITestLaunch.swift | 6 ------ Kanban/KanbanApp.swift | 28 +++++++++++++++++++++++++-- KanbanTests/UITestLaunchTests.swift | 8 -------- 5 files changed, 48 insertions(+), 28 deletions(-) diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 9f37968..bb5f63b 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -310,16 +310,22 @@ public final class AppModel { /// 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. - 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 windowDismisser = dismiss - guard !pendingOpenURLs.isEmpty else { return } + guard !pendingOpenURLs.isEmpty else { return 0 } let urls = pendingOpenURLs pendingOpenURLs.removeAll() for url in urls { openBoard(at: url) } + return urls.count } // MARK: Recents diff --git a/Kanban/App/RestoreBootstrapView.swift b/Kanban/App/RestoreBootstrapView.swift index dfa3fcf..1c9af17 100644 --- a/Kanban/App/RestoreBootstrapView.swift +++ b/Kanban/App/RestoreBootstrapView.swift @@ -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 /// 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. +/// 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 /// @@ -63,24 +64,27 @@ struct RestoreBootstrapView: View { 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) + // 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` 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() + // `.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() { - var attempted = 0 + private func restoreFlaggedBoards(openedAlready: Int) { + var attempted = openedAlready for board in appModel.boardRegistry.restorables() { switch board { case let .available(_, url): diff --git a/Kanban/App/UITestLaunch.swift b/Kanban/App/UITestLaunch.swift index 10359c1..05ccd4f 100644 --- a/Kanban/App/UITestLaunch.swift +++ b/Kanban/App/UITestLaunch.swift @@ -42,12 +42,6 @@ enum LaunchPlan: Equatable, Sendable { ) ? .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 diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 0471eb8..b338fe0 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -41,6 +41,17 @@ struct KanbanApp: App { private let launchPlan: LaunchPlan 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 // launch keeps its recents and its clipboard snapshots in the scratch directory rather than in // the shared App Group container. @@ -73,7 +84,12 @@ struct KanbanApp: App { .environment(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) // "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen & // templates). `.contentMinSize` rather than `.contentSize`, because the view states a @@ -104,7 +120,15 @@ struct KanbanApp: App { .environment(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) .windowStyle(.plain) .defaultSize(width: 1, height: 1) diff --git a/KanbanTests/UITestLaunchTests.swift b/KanbanTests/UITestLaunchTests.swift index 824b0a2..faaf113 100644 --- a/KanbanTests/UITestLaunchTests.swift +++ b/KanbanTests/UITestLaunchTests.swift @@ -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