From 2a368a04f72dc36360602f12845a3a33ae45a667 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 14:34:08 +0200 Subject: [PATCH] feat(window): persist window size + position across app launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Core/SwiftUI/WindowFrameAutosave.swift | 73 +++++++++++++++++++ scarf/scarf/Localizable.xcstrings | 8 ++ scarf/scarf/scarfApp.swift | 6 ++ 3 files changed, 87 insertions(+) create mode 100644 scarf/scarf/Core/SwiftUI/WindowFrameAutosave.swift diff --git a/scarf/scarf/Core/SwiftUI/WindowFrameAutosave.swift b/scarf/scarf/Core/SwiftUI/WindowFrameAutosave.swift new file mode 100644 index 0000000..d792065 --- /dev/null +++ b/scarf/scarf/Core/SwiftUI/WindowFrameAutosave.swift @@ -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)) + } +} diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index 4d0e1bc..7afbbf7 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -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 diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index a014739..8094cef 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -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() } }