Stand up the window architecture — welcome, board, card

Four scenes (welcome, restore bootstrap, board group, card group) with
system restoration disabled in favor of the registry's open-now flags:
set when a window actually opens, cleared only on user close, so quit —
and crash — leave exactly the restoration set behind. AppModel joins
windows to sessions (shared store, registry record, card refs, held
security scope); CloseFlushCoordinator pins 02's strict close order as
a seam-injected machine (card sessions end, windows drain, store
flushes, record stamps, teardown) with named slots where m6/m7 flushes
land. HostedWindowController proxies — never replaces — SwiftUI's
window delegate to intercept windowShouldClose for the flush, report
frames, and place saved frames onto live screens. Card windows are
(board path, case-folded card id) values: reopen focuses, and a
snapshot-pure fate function dismisses on delete, tombstone, tombstoned
lane, or cross-board move.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 07:47:11 -04:00
parent 61e18c3dfa
commit fccdf56cf4
19 changed files with 2767 additions and 6 deletions
+214
View File
@@ -0,0 +1,214 @@
import AppKit
import SwiftUI
import os
// MARK: - HostedWindowController
/// The `NSWindow` behind a SwiftUI scene, and the three things this app needs from it that SwiftUI
/// does not expose: the window's frame as the user changes it, a chance to run work *before* the
/// window closes, and the window object itself for placement.
///
/// ### The delegate is proxied, never replaced
///
/// SwiftUI owns its windows' delegates and uses them scene teardown, tabbing, restoration all ride
/// through it so assigning `window.delegate = self` and walking away breaks the window in ways
/// that show up much later and look like SwiftUI bugs. This object therefore **inserts itself in
/// front** of whatever delegate is already there: it implements the three methods it cares about and
/// forwards them on by hand, and for every other selector it claims to respond exactly when the
/// previous delegate does and forwards the message wholesale through `forwardingTarget(for:)`. The
/// `responds(to:)` override is what makes that safe `NSWindow` caches which delegate methods exist
/// at the moment the delegate is set, and a proxy that under-reported would silently swallow half of
/// SwiftUI's own callbacks.
///
/// The alternative that was considered and rejected: observing `NSWindow.willCloseNotification`
/// instead of intercepting `windowShouldClose`. It cannot work for the close flush by the time
/// that notification arrives the close has already been decided, and the flush's whole job is to
/// happen *first* (02-architecture.md § Windows). Move and resize, which have nothing to veto, could
/// have gone either way; they are delegate methods here so there is one mechanism rather than two.
@MainActor
final class HostedWindowController: NSObject, NSWindowDelegate {
/// The window, once the view hierarchy has one. Weak: the window owns the view that owns nothing
/// here, and a strong reference would keep a closed window alive.
private(set) weak var window: NSWindow?
/// Whoever was the delegate before us SwiftUI's own, in practice. Weak for the same reason
/// `NSWindow.delegate` is: it is not ours to keep alive.
///
/// `nonisolated(unsafe)` because the two proxying overrides below (`responds(to:)` and
/// `forwardingTarget(for:)`) override `NSObject` methods that are not actor-isolated and cannot
/// be made so. The property is written only on the main actor, and every read is a message the
/// Objective-C runtime is delivering to a window delegate which AppKit does on the main thread.
/// The alternative, `MainActor.assumeIsolated`, would turn any hypothetical off-main
/// `respondsToSelector:` into a crash; a stale read of a weak reference is the milder failure.
private nonisolated(unsafe) weak var previousDelegate: NSWindowDelegate?
/// Called once, when the window first appears. Placement (the saved frame, the card cascade)
/// happens here.
var onAttach: ((NSWindow) -> Void)?
/// Called on `windowDidMove` and at the end of a live resize not during one, because saving a
/// frame per mouse-moved event would write the registry file hundreds of times for one drag.
var onFrameChanged: ((NSRect) -> Void)?
/// Called instead of closing, when non-`nil`. The handler runs the close flush and then closes
/// the window itself through `closeAfterFlush()`. `nil` means "close normally", which is every
/// window that has nothing to flush.
var onCloseRequested: (() -> Void)?
/// Set by `closeAfterFlush()` so the re-entrant `windowShouldClose` lets the close through
/// instead of starting a second flush.
private var isFlushed = false
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment
func attach(to window: NSWindow) {
guard self.window !== window else { return }
self.window = window
if window.delegate !== self {
// Guarding against self-proxying: re-attaching to a window we already front would
// otherwise make `previousDelegate` point at this object and every forwarded selector an
// infinite loop.
previousDelegate = window.delegate
window.delegate = self
}
onAttach?(window)
}
/// Puts the previous delegate back. Called when the hosting view goes away; a no-op if something
/// else has since taken the delegate, because stomping a third party's would be the bug this
/// whole file exists to avoid.
func detach() {
guard let window, window.delegate === self else { return }
window.delegate = previousDelegate
self.window = nil
}
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
/// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside.
func closeAfterFlush() {
isFlushed = true
window?.performClose(nil)
}
// MARK: NSWindowDelegate
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard !isFlushed, let onCloseRequested else {
return previousDelegate?.windowShouldClose?(sender) ?? true
}
onCloseRequested()
// The window stays open with everything still on screen while the flush runs which is also
// what makes 02's "close waits for in-flight operations" implementable here later: the
// banner's spinner has somewhere to spin.
return false
}
func windowDidMove(_ notification: Notification) {
reportFrame()
previousDelegate?.windowDidMove?(notification)
}
func windowDidEndLiveResize(_ notification: Notification) {
reportFrame()
previousDelegate?.windowDidEndLiveResize?(notification)
}
private func reportFrame() {
guard let window else { return }
onFrameChanged?(window.frame)
}
// MARK: Proxying
override func responds(to aSelector: Selector!) -> Bool {
if super.responds(to: aSelector) { return true }
return previousDelegate?.responds(to: aSelector) ?? false
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
guard let previousDelegate, previousDelegate.responds(to: aSelector) else { return nil }
return previousDelegate
}
// MARK: Placement
/// Where a saved frame should actually open the settled rule in 02-architecture.md § Windows,
/// "per-board frame memory (repositioned onto a live screen if the saved one is gone)".
///
/// Pure, and taking the screens as an argument, because the interesting case is a display that is
/// *not attached right now*: a board last closed on an external monitor must not reopen at
/// coordinates nobody can see. Asking `NSScreen` inside would make that untestable and would
/// hide the rule inside a window callback.
///
/// Intersection, not containment, is the test: a window straddling two displays or hanging
/// slightly off the bottom of one is where the user left it, and AppKit's own
/// `constrainFrameRect(_:to:)` nudges the remainder into view when the frame is set. Only a frame
/// that lands on *no* live screen is relocated, and then it keeps its size and centers on the
/// fallback size is a preference, position is a place, and the place is what stopped existing.
static func placement(for saved: WindowFrame, onScreens visibleFrames: [NSRect], fallback: NSRect) -> NSRect {
let frame = NSRect(x: saved.x, y: saved.y, width: saved.width, height: saved.height)
if visibleFrames.contains(where: { $0.intersects(frame) }) {
return frame
}
return NSRect(
x: fallback.midX - frame.width / 2,
y: fallback.midY - frame.height / 2,
width: frame.width,
height: frame.height
)
}
/// `placement(for:onScreens:fallback:)` against the screens attached right now.
static func placementOnCurrentScreens(for saved: WindowFrame) -> NSRect {
let visibleFrames = NSScreen.screens.map(\.visibleFrame)
let fallback = NSScreen.main?.visibleFrame ?? visibleFrames.first ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
return placement(for: saved, onScreens: visibleFrames, fallback: fallback)
}
}
// MARK: - WindowAccessor
/// Hands a SwiftUI view's `NSWindow` to a `HostedWindowController`.
///
/// A zero-size, hidden `NSView` whose only job is `viewDidMoveToWindow()` the moment AppKit itself
/// declares the window known. The alternative idiom (read `view.window` from a dispatched block after
/// `makeNSView`) is a guess about timing that is usually right; this one is never wrong.
struct WindowAccessor: NSViewRepresentable {
let controller: HostedWindowController
func makeCoordinator() -> HostedWindowController { controller }
func makeNSView(context: Context) -> NSView {
let view = WindowSensingView()
view.onWindow = { [controller] window in
controller.attach(to: window)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
static func dismantleNSView(_ nsView: NSView, coordinator: HostedWindowController) {
coordinator.detach()
}
}
/// Draws nothing and wants no space it is a hook wearing a view's clothes. Hosted as a
/// `.background`, so even its zero-size frame is out of the layout's way.
private final class WindowSensingView: NSView {
var onWindow: ((NSWindow) -> Void)?
override var intrinsicContentSize: NSSize { .zero }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let window else { return }
onWindow?(window)
}
}