Skip to main content

SwiftUI not restoring the window size on macOS

5 min read

SwiftUI restores a Mac app's windows by itself: move the window, resize it, quit, and the next launch puts it back where it was. Apple's Tailor macOS windows with SwiftUI treats that as the default, with .restorationBehavior mostly there to turn it off, and defaultSize only counts on the very first launch, when there's nothing to restore yet.

In Continuum, a Mac app I make for keeping notes on the people you manage, it just didn't. The window opened at defaultSize, centered, on every single launch, no matter where I'd left it or how big I'd made it, and there was nothing in the logs to say why.

It had been like that from the start, and I only found out when a user told me the default size was too big on a 13" screen. A window that remembers its size would've made that a one-time resize.

I went through the usual suspects, more or less in this order:

  • The "Close windows when quitting an application" setting in Desktop & Dock.
  • Whether defaultSize was somehow winning over the restored frame.
  • NSWindow.setFrameAutosaveName, which loses to SwiftUI: it applies defaultSize again once the window is already on screen.
  • An AppKit store for the window's position, which never restored either.
  • A WindowSizeStore of my own, saving the content size and feeding it back through .defaultSize. That one actually worked, and I reverted it once the real cause turned up.

All plausible, none of them the cause.

What actually helped was a pretty boring step — a new, empty project with the same scene modifiers restored its window just fine. So the question stopped being how the API works, and became what's different in my app.

Next, the app itself. I stripped Continuum's window down to Text("Hello") and went through the modifiers one at a time, commenting them in and out. The one that made the difference was the app lock, a ViewModifier that covers the whole app with a lock screen (an optional Touch ID or password lock, off by default), and it kept breaking restoration even with its body cut down to nothing:

// ContinuumApp.swift
WindowGroup {
   ContentView()
      // [...]
      .appLockGate() // ❶
}
.defaultSize(width: .windowDefaultWidth, height: .windowDefaultHeight)
 
// AppLockGate.swift
private struct AppLockGate: ViewModifier { // ❷
   func body(content: Content) -> some View {
      content
   }
}
 
extension View {
   func appLockGate() -> some View {
      modifier(AppLockGate())
   }
}

A modifier that does nothing, applied straight to the window's content (❶), and the window still forgot its frame. With that little code, the one word left to look at was private (❷). I took it out, and the window came back where I'd left it.

Why would an access level change anything at runtime?

Private types at the window root

A modifier applied directly to the WindowGroup's content becomes part of the window's root view type, something like ModifiedContent<…, AppLockGate>. Modifiers inside ContentView's body don't count, since they aren't part of ContentView's own type; Continuum has a few private ones in there (demoModeBanner, borderedCard), and they're fine.

A private type's name isn't stable, though. We can check with String(reflecting:), in a tiny command-line tool whose module is called Probe:

private struct Gate {}
fileprivate struct FileGate {}
struct Open {}
 
print(String(reflecting: Gate.self))     // Probe.(unknown context at $104b00c38).Gate
print(String(reflecting: FileGate.self)) // Probe.(unknown context at $104b00c5c).FileGate
print(String(reflecting: Open.self))     // Probe.Open

Run it again and the first 2 lines print $102444c38 and $102444c5c instead: the binary is loaded at a different address on every launch, and the address is in the name. Open stays Probe.Open every time.

I can't say for sure that this is what breaks restoration, since nothing documents how SwiftUI keys a window's saved state. But if it uses that type's name in any way, each launch would look for a frame the previous launch never saved, find nothing and quietly fall back to defaultSize, which is exactly what it looked like. It also means fileprivate is no fix, since its name has the same address in it; I'd keep anything applied at the window root at least internal.

AppLockGate is internal now, and I put this at the top of its doc comment, since every other ViewModifier in Continuum is private (as it should be) and the first access-level tidy-up would've put the bug right back:

/// **Internal, never `private` — do not "tidy" this.** [...]
struct AppLockGate: ViewModifier {

So, if a Mac app won't remember its window, check what's applied directly to the WindowGroup's content before anything else, and make sure none of it is private or fileprivate.

If you know where this is documented, or why it fails without a single log line, I'm all ears: @roland.leth.ro or @rolandleth!