feat(window): persist window size + position across app launches

SwiftUI's WindowGroup exposes `.defaultSize` and `.windowResizability`
but no built-in autosave for window frame across launches. The
documented escape hatch is AppKit's
`NSWindow.setFrameAutosaveName(_:)`, which writes the frame to
UserDefaults on resize/move and restores it on next open.

Add a small `WindowFrameAutosave` NSViewRepresentable that finds its
hosting NSWindow on first appear and stamps the autosave name. Apply
it to `ContextBoundRoot` keyed off `context.id` so each open server
window remembers its own geometry. New servers fall back to the
WindowGroup's `.defaultSize(1100, 700)` until the user resizes once.

A previous WIP attempt (dd4a61f) tried to use a fictional
`.windowFrameAutosaveName(...)` SwiftUI modifier that doesn't exist —
which is why it was never merged. This works because we go through
AppKit directly.

Also picks up Xcode's auto-extracted cron-related Localizable.xcstrings
entries that had been pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 14:34:08 +02:00
parent 9aa901a286
commit 2a368a04f7
3 changed files with 87 additions and 0 deletions
@@ -0,0 +1,73 @@
import AppKit
import SwiftUI
/// Persist a SwiftUI `WindowGroup` window's frame (size + position) across
/// app launches by hooking into AppKit's `NSWindow.setFrameAutosaveName`.
///
/// **Why this exists.** SwiftUI's `WindowGroup` exposes `.defaultSize`,
/// `.windowResizability`, and (on macOS Sonoma+) various scene modifiers
/// but not a "remember this window's size between launches" affordance.
/// Apple's documented escape hatch is AppKit's `setFrameAutosaveName(_:)`,
/// which writes the window's frame to UserDefaults on resize/move and
/// reads it back on next `makeKey`. We bridge into it from SwiftUI via an
/// invisible `NSViewRepresentable` that finds the hosting `NSWindow`
/// and stamps the autosave name once it appears.
///
/// **Usage.**
/// ContentView()
/// .windowFrameAutosave("Scarf.\(context.id)")
///
/// Pass a stable identifier per logical window. Different identifiers per
/// window are required by AppKit ("no two windows can be associated with
/// the same name simultaneously" `NSWindow.setFrameAutosaveName(_:)`
/// docs). For Scarf's multi-window-per-server model, keying off
/// `ServerID` gives each server window its own remembered frame.
///
/// **First-launch behaviour.** No saved frame exists AppKit leaves the
/// window at whatever frame SwiftUI's `.defaultSize` produced. After the
/// first user resize, AppKit autosaves and subsequent opens restore the
/// new frame.
///
/// **What it doesn't do.** Doesn't capture/restore fullscreen state
/// (AppKit handles that separately and reasonably). Doesn't try to
/// override window state restoration when the user has the system-level
/// "Close windows when quitting an application" setting OFF that
/// pathway runs first and we just ride alongside.
struct WindowFrameAutosave: NSViewRepresentable {
let name: String
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
// The hosting NSWindow isn't attached to this view yet at
// makeNSView time SwiftUI mounts the AppKit view hierarchy
// before the window assignment propagates. Defer one runloop
// iteration so `view.window` is non-nil when we stamp.
DispatchQueue.main.async { [weak view] in
view?.window?.setFrameAutosaveName(name)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// SwiftUI may swap the host window in rare cases (window
// restoration after a relaunch, scene reuse). Re-stamp on
// update so we don't lose the autosave binding silently.
// setFrameAutosaveName is idempotent for the same name on
// the same window; assigning the same name twice is a no-op.
DispatchQueue.main.async { [weak nsView] in
guard let window = nsView?.window else { return }
if window.frameAutosaveName != name {
window.setFrameAutosaveName(name)
}
}
}
}
extension View {
/// Persist this view's hosting window's frame (size + position)
/// across launches under `name`. See `WindowFrameAutosave` for
/// details.
func windowFrameAutosave(_ name: String) -> some View {
background(WindowFrameAutosave(name: name))
}
}
+8
View File
@@ -11385,6 +11385,10 @@
}
}
},
"LAST RUN OUTPUT" : {
"comment" : "A label displayed above the last run output.",
"isCommentAutoGenerated" : true
},
"Last run: %@" : {
"extractionState" : "stale",
"localizations" : {
@@ -13909,6 +13913,10 @@
"comment" : "A message that appears when the user is not logged in to Nous Portal.",
"isCommentAutoGenerated" : true
},
"No output yet — this job hasn't run, or its output file is gone." : {
"comment" : "A message displayed when a cron job's output",
"isCommentAutoGenerated" : true
},
"No output yet." : {
"comment" : "A message displayed when a tool call has not yet produced output.",
"isCommentAutoGenerated" : true
+6
View File
@@ -252,6 +252,12 @@ private struct ContextBoundRoot: View {
// title gives macOS Mission Control / ` cycling a meaningful
// label so users can pick the right window without focusing it.
.navigationTitle("Scarf — \(context.displayName)")
// Persist this window's frame (size + position) across
// launches via AppKit's NSWindow.frameAutosaveName. The
// autosave name is per-server so each open server window
// remembers its own geometry; new servers fall back to
// WindowGroup's `.defaultSize` until first resize.
.windowFrameAutosave("Scarf.Window.\(context.id)")
.onAppear { fileWatcher.startWatching() }
.onDisappear { fileWatcher.stopWatching() }
}