Files
lanework/Kanban/App/WindowAccessor.swift
T
rzen fda19881de A stale window dismantle stops undoing a fresher attach — the card window keeps its toolbar across a raw-source toggle
Toggling a card window between Edit and Raw Source could leave it with no toolbar at
all, which collapses AppKit's two-line title-and-subtitle chrome down to the single
combined "⟨title⟩ — ⟨board⟩ › ⟨lane⟩" line (the malformed titlebar reported on the
Pipeline card) — that stacked rendering only appears when a toolbar is installed.

Root cause was in HostedWindowController.attach/detach (WindowAccessor.swift), shared
by every window this app hosts. WindowAccessor's own doc comment already recorded that
SwiftUI "dismantles and re-makes the background representable" on macOS 26, and every
slot attach()/detach() manage was made repeat-safe against that (BoardChromeTests
.theSlotReappliesToTheNextWindow pins it for extendsUnderTitlebar) — but that safety
net assumes a dismantle always arrives before its matching attach, and nothing
guarantees that ordering. A content swap deep in the card window's tree (the raw-source
outlet replacing the whole content area, or an edit-mode flush landing a reload) is the
kind of churn that can make SwiftUI recreate the representable mid-session. If the old
view's dismantleNSView lands after the new view's attach has already reinstalled the
toolbar, the old identity-blind detach() had no way to tell — its guards check "is my
state still installed", which is coincidentally true right after a fresh reattach too —
so it tore the toolbar, the titlebar accessory and the delegate proxy right back off a
window a newer attach had just finished configuring, with nothing left to reinstall it.

Fix: attach(to:through:)/detach(through:) track which WindowAccessor view is the
current owner (HostedWindowController.attachedThroughView) and refuse a detach for any
other view outright. WindowAccessor.makeNSView/dismantleNSView pass their own view
through; every existing bare attach(to:)/detach() caller (this file's own tests,
BoardChromeTests, InlineEditWriteTests, HistoryProviderTests) is untouched — the guard
only engages when both sides of a call name a view. State re-asserted at the ownership
point rather than a notification-race band-aid, the same shape a429a7e's
titlebar-transparency fix used.

Could not reproduce live — the screen is locked in this environment (CGSSessionScreen
IsLocked). Established the mechanism from code and verified it with a targeted harness
instead: ToolbarStaleDismantleTests (ToolbarTests.swift) drives HostedWindowController
directly through the exact race (attach view A, attach view B over the same still-live
window, then a stale detach for view A), confirms the toolbar and delegate survive, and
separately confirms the legitimate owner's detach, the ordinary detach-then-attach
order, and every viewless caller all behave exactly as before. Verified the new test
fails without the fix (temporarily disabled the identity guard, reran in isolation, saw
the expected failure) before restoring it.

Tests: 3223 KanbanTests, 3220 passing. The only 3 failures are PointerLatencyTests'
documented locked-screen environmental mode (CGEvent-driven clicks need a live screen)
— reran that suite alone and got the identical 3 failures, none of which touch this
window-attachment code.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 12:06:01 -04:00

498 lines
27 KiB
Swift

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)?
/// **The stack this window's ⌘Z crosses**, asked for afresh every time AppKit wants it —
/// 13-native-undo.md ▸ Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
///
/// A closure rather than a stored manager for two reasons: a board window's session does not
/// exist yet when the window attaches, and it stops existing at teardown while the window is
/// still closing — answering `nil` then is what keeps a torn-down board's stack from being
/// reachable through a window that outlived it by a run-loop turn.
///
/// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default".
var windowUndoManager: (() -> UndoManager?)?
/// The text manager this window hands back while a field editor holds the keyboard, and the one
/// it hands back when there is no board — 06-history-undo.md ▸ Undo routing, via
/// `BoardUndoRouting`. Created on demand, per window, which is what AppKit itself would have
/// done for a window whose delegate answered nothing.
private lazy var textUndoManager = UndoManager()
/// Set by `closeAfterFlush()` so the re-entrant `windowShouldClose` lets the close through
/// instead of starting a second flush.
private var isFlushed = false
/// The titlebar accessory this window shows, once something has given it one — today the board
/// popover's window-title widget (03-board-ui.md § Board popover), and only on board windows.
/// `nil` on welcome, the bootstrap and card windows, which is why it is a slot rather than a
/// constructor argument.
private var titlebarAccessory: NSTitlebarAccessoryViewController?
/// This window's toolbar, once something has given it one — the board and card windows'
/// customizable toolbars (03-board-ui.md ▸ Toolbar). A slot for the accessory's reason: welcome
/// and the bootstrap window have none, and the two that do only learn what goes in it after
/// their board has loaded.
private var toolbarController: WindowToolbarController?
/// Whether this window's title is hidden from the title bar — **card and board windows**: the
/// card's name is shown as part of the card's body instead of the chrome (05-card-window.md ▸
/// Window), and the board's is said by the board-popover widget in the titlebar instead
/// (03-board-ui.md ▸ Board popover; `BoardWindowHost.configureWindow`). `nil` leaves AppKit's own
/// default (`.visible`) untouched — the restore-bootstrap window's posture, the one
/// `HostedWindowController`-hosted window with no opinion here, the same "nothing to do" posture
/// `titlebarAccessory` has on welcome (which never attaches a controller at all), the bootstrap
/// window, and now — for that slot specifically — card windows too.
///
/// A slot, not a one-shot write, for the accessory and toolbar's own reason: the value has to
/// survive the provisional-window swap (`detach()`'s doc comment) and reapply itself when the
/// real window attaches, which a write made once at `onAttach` time would not survive if that
/// closure only fired for the provisional window. `NSWindow.title` itself is a different slot
/// entirely — SwiftUI's `navigationTitle` sets it directly, and it is left alone on purpose: the
/// Window menu, Mission Control/Exposé, VoiceOver and state restoration all read the string, not
/// what the chrome draws from it.
private var titleVisibility: NSWindow.TitleVisibility?
/// Whether this window's content runs the full height of the frame, under a transparent title
/// bar — **a board window carrying a custom background**, and nothing else (03-board-ui.md §
/// Styling ▸ Capabilities: the board's colour or image "paints the full window"; `BoardView
/// .boardBackground` draws the frosted strip that keeps the chrome legible over it).
///
/// `nil` leaves AppKit's own posture untouched, exactly as `titleVisibility` does — the welcome,
/// bootstrap and card windows have no opinion, and neither does a board window while it loads
/// (the flag is driven off the snapshot, which does not exist yet). `nil` and `false` therefore
/// render identically; they differ only in whether this controller has *said* anything, which is
/// what keeps the loading half from having to state a default it does not own.
///
/// A slot rather than a one-shot write, and **repeat-safe rather than install-once** — the
/// `hideTitle` pattern, for a stronger version of its reason: the value has to survive the
/// provisional-window swap (`detach()`), *and* it genuinely changes over a window's life. A
/// `background:` edited on disk reloads the snapshot, and the chrome follows it in both
/// directions.
private var extendsUnderTitlebar: Bool?
/// **Which `WindowAccessor` view this controller is currently attached through** — the identity
/// `detach(through:)` checks before it tears anything down.
///
/// SwiftUI's dismantle-then-make pair for the background representable is not guaranteed to
/// arrive in the order it logically pairs in (`detach()`'s own doc comment: "observed on macOS
/// 26" is an empirical note, not a documented ordering). A content swap deep in a window's tree —
/// the raw-source outlet replacing the whole card-window content area is the one this was traced
/// to — can make SwiftUI recreate this representable, and if the *old* instance's `dismantleNSView`
/// is delivered **after** the *new* instance has already attached, an identity-blind `detach()`
/// would tear down the toolbar, the titlebar accessory and the delegate proxy that the newer
/// attach just installed — leaving the window with no toolbar at all, which collapses a two-line
/// title-and-subtitle down to AppKit's single combined line (the malformed titlebar this guards
/// against). Tracking *which* view is the current owner is what lets a stale teardown recognize
/// itself as stale and refuse, rather than winning a race it does not know it is in.
///
/// `nil` for every caller that attaches without naming a view — every direct call in this file's
/// own tests — which keeps their `attach`/`detach` pair exactly as unconditional as it always was;
/// the guard only ever engages when both sides of a call name one.
private weak var attachedThroughView: NSView?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment
/// - Parameter view: The `WindowAccessor`'s own sensing view, when the caller is one — `nil` for
/// a caller with no view of its own (every direct call this file's tests make), which attaches
/// exactly as before. Recorded **before** the same-window early return, so a fresh view mounted
/// over an unchanged window still updates who owns the next `detach(through:)`.
func attach(to window: NSWindow, through view: NSView? = nil) {
if let view {
attachedThroughView = view
}
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)
// After `onAttach`, so placement has already happened: an accessory handed over before the
// window existed is installed here instead, and one handed over later installs immediately.
addTitlebarAccessoryIfPossible()
applyToolbarIfPossible()
applyTitleVisibilityIfPossible()
applyTitlebarExtensionIfPossible()
}
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
/// **without discarding them**. The delegate half is 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.
///
/// The held chrome survives a detach deliberately: its lifetime is this controller's, not the
/// sensing view's. SwiftUI dismantles and re-makes the background representable while it moves a
/// scene's content into its final window (observed on macOS 26: install arrives before any
/// window, a dismantle follows, and only *then* does the real window attach) — so chrome
/// discarded here would never reach the window it was made for. `attach` reinstalls whatever is
/// held; a controller that is genuinely done takes its slots down with it.
///
/// - Parameter view: The `WindowAccessor` view being dismantled, when the caller is one — `nil`
/// for a caller with no view (every direct call this file's tests make), which detaches exactly
/// as before, unconditionally. A named view that is **not** the one `attachedThroughView` last
/// recorded is refused outright: a fresher `attach(to:through:)` already owns this window's
/// chrome, and this call is a stale teardown arriving for a view that lost the race — see
/// `attachedThroughView`.
func detach(through view: NSView? = nil) {
if let view, view !== attachedThroughView { return }
removeTitlebarAccessory()
removeToolbar()
guard let window, window.delegate === self else { return }
window.delegate = previousDelegate
self.window = nil
}
// MARK: Titlebar accessory
/// Gives this window a titlebar accessory — **once**, whatever the caller does.
///
/// The guard is the whole of the install-once rule: AppKit keeps accessories in an array and
/// would happily hold two identical widgets, and a board window's host may configure itself more
/// than once (the load returns, the window attaches, SwiftUI re-evaluates). Installing before
/// the window exists is legal — the accessory is held and goes in at `attach`.
func installTitlebarAccessory(_ accessory: NSTitlebarAccessoryViewController) {
guard titlebarAccessory == nil else { return }
titlebarAccessory = accessory
addTitlebarAccessoryIfPossible()
}
private func addTitlebarAccessoryIfPossible() {
guard let window, let accessory = titlebarAccessory,
!window.titlebarAccessoryViewControllers.contains(where: { $0 === accessory })
else { return }
window.addTitlebarAccessoryViewController(accessory)
}
/// Removes ours and only ours, by identity: the index is looked up rather than assumed, because
/// nothing promises this app owns the only accessory a window carries.
private func removeTitlebarAccessory() {
guard let window, let accessory = titlebarAccessory,
let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === accessory })
else { return }
window.removeTitlebarAccessoryViewController(at: index)
}
// MARK: Toolbar
/// Gives this window its toolbar — **once**, `installTitlebarAccessory`'s rule and for its
/// reason: a host may configure itself more than once, and a second toolbar would replace the
/// first along with the search field the first was hosting.
///
/// Installing before the window exists is legal — the toolbar is held and goes on at `attach`.
func installToolbar(_ controller: WindowToolbarController) {
guard toolbarController == nil else { return }
toolbarController = controller
applyToolbarIfPossible()
}
/// The window is SwiftUI's, and SwiftUI leaves `toolbar` alone for a scene that declares no
/// `.toolbar` modifier — which the board and card windows deliberately do not, since their
/// toolbars are `NSToolbar`s (see `WindowToolbarController` for why). The identity check is what
/// keeps a re-attach from replacing a live toolbar with itself and rebuilding every item.
private func applyToolbarIfPossible() {
guard let window, let toolbarController, window.toolbar !== toolbarController.toolbar else { return }
window.toolbar = toolbarController.toolbar
// The first honest answer to "what is installed", now that the toolbar has built its items
// from the saved configuration.
toolbarController.reportInstalledItems()
toolbarController.revalidate()
}
private func removeToolbar() {
guard let window, let toolbarController, window.toolbar === toolbarController.toolbar else { return }
window.toolbar = nil
}
/// Re-validates this window's toolbar items against their current predicates, on demand.
///
/// `WindowToolbarController`'s own observation tracking (`trackValidationState`) only notices
/// `@Observable` reads changing; a predicate that reads a plain `UserDefaults`-backed bit instead
/// (`AppPreferences.showCardSidebar`, `CardToolbar`) is invisible to it. A no-op before a toolbar
/// exists, which covers every window kind that has none.
func revalidateToolbar() {
toolbarController?.revalidate()
}
// MARK: Title visibility
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
/// — the card-window seam (`CardWindowHost`, 05-card-window.md ▸ Window) and, since the
/// board-popover widget grew to say the board's name itself, the board-window one too
/// (`BoardWindowHost`, 03-board-ui.md ▸ Board popover). `window.title` is untouched by this call
/// on purpose; see the property's doc comment for why.
///
/// Safe to call whenever the caller learns it wants this — before the window exists (the value is
/// held and applied at `attach`) or after (applied immediately) — and safe to call more than once,
/// unlike the accessory and toolbar slots: writing `NSWindow.titleVisibility` twice has no side
/// effect worth guarding against, so this is not an install-once seam.
func hideTitle() {
titleVisibility = .hidden
applyTitleVisibilityIfPossible()
}
private func applyTitleVisibilityIfPossible() {
guard let window, let titleVisibility else { return }
window.titleVisibility = titleVisibility
}
// MARK: Content under the title bar
/// Runs this window's content the full height of its frame, under a transparent title bar — or
/// puts the standard chrome back (see `extendsUnderTitlebar`).
///
/// Safe whenever the caller learns the answer — before the window exists (held, applied at
/// `attach`) or after (applied now) — and safe to call repeatedly with the same value, which
/// matters more here than for `hideTitle`: the board window drives this off its snapshot, so it
/// is called on every reload that changes the reading and on plenty that do not.
///
/// **Not undone at `detach`**, `titleVisibility`'s posture: the slot survives the provisional-
/// window swap and reapplies itself to whichever window attaches next, and a window that is
/// genuinely going away takes its chrome with it.
func setExtendsContentUnderTitlebar(_ flag: Bool) {
extendsUnderTitlebar = flag
applyTitlebarExtensionIfPossible()
}
/// The two AppKit knobs the effect needs, and they are one decision: `fullSizeContentView` is
/// what lets the content view reach under the title bar, and `titlebarAppearsTransparent` is
/// what stops the title bar from painting its own material over it. Either alone is a visible
/// half-state — an opaque bar over the board, or a board that stops at a bar that no longer
/// draws.
///
/// **The second knob does not stay written, and this is not the place that keeps it.** On a
/// SwiftUI scene window `titlebarAppearsTransparent` is SwiftUI's: it is the AppKit face of the
/// view tree's resolved `toolbarBackgroundVisibility(for: .windowToolbar)`, and SwiftUI writes
/// the resolved value — changed or not — on every pass in which it re-applies a window's
/// configuration. A tree that states nothing resolves `.automatic`, so each of those passes put
/// `false` back over what this method had set, and the board's title bar went opaque some time
/// after the board opened rather than at once. So the board *also* says it in SwiftUI
/// (`BoardWindowHost`, which owns the reasoning), and this write is what makes the posture true
/// for the turn in which the board's `background:` first reads, not what makes it last.
///
/// The `fullSizeContentView` half has no such second owner and survives on its own.
private func applyTitlebarExtensionIfPossible() {
guard let window, let extendsUnderTitlebar else { return }
window.titlebarAppearsTransparent = extendsUnderTitlebar
if extendsUnderTitlebar {
window.styleMask.insert(.fullSizeContentView)
} else {
window.styleMask.remove(.fullSizeContentView)
}
}
/// 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
}
/// The window-level half of 06-history-undo.md ▸ Undo routing (see `BoardUndoRouting`, which
/// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
/// text manager of this window's own while a field editor has it.
///
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
/// condition that this window has a stack: `responds(to:)` reports this method whatever the
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
/// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
/// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = windowUndoManager?()
if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
return inherited
}
return BoardUndoRouting.undoManager(
isTextEditing: BoardUndoRouting.isTextEditing(window.firstResponder),
board: board,
textFallback: textUndoManager
)
}
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()
// **Named**, not bare — this instance is the token `attach(to:through:)`/`detach(through:)`
// race-guard on (`HostedWindowController.attachedThroughView`), so a stale dismantle for a
// view a later `makeNSView` has already superseded refuses to undo that newer attach's work.
view.onWindow = { [controller, weak view] window in
guard let view else { return }
controller.attach(to: window, through: view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
static func dismantleNSView(_ nsView: NSView, coordinator: HostedWindowController) {
coordinator.detach(through: nsView)
}
}
/// 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)
}
}