Desktop window state#
The desktop main window remembers its last normal size separately from maximize / fullscreen chrome. Restoring a previous session never creates a normal window whose edges sit flush with the menu bar, Dock, or screen sides — that is the “stuck, cannot resize” class of bugs.
Related code: apps/desktop/window-state.js, apps/desktop/main.js,
apps/web/app/styles/base.css, apps/web/components/app-shell.tsx.
Why a flush normal window cannot be resized#
Electron’s BrowserWindow is resizable. Persistence used to write only
{x,y,width,height} from win.getBounds() to
app.getPath("userData")/window-state.json (desktop name
openprogram-desktop). Zoom or a drag that fills the display work area,
then quit, saved those exact pixels as a normal window.
On a 1512-wide logical display that is {x:0,y:38,width:1512,height:851}:
edges sit on the menu bar, Dock, and screen sides, so there is nothing to
grab. The green Zoom button also has no remembered restore size.
-webkit-app-region: drag on the 40px desktop tab row used to run
edge-to-edge, which also swallowed the top-edge and corner resize hit
targets.
Setting resizable: true or deleting the state file does not address this.
The stored model has to distinguish chrome from normal bounds.
Persisted schema#
The file is window-state.json under the desktop userData directory.
Version 2 is the current shape. A version-1 file is the legacy
{x,y,width,height} object and migrates on load.
{
"version": 2,
"x": 160,
"y": 90,
"width": 1280,
"height": 800,
"isMaximized": false,
"isFullScreen": false,
"displayId": 1,
"displayWorkArea": { "x": 0, "y": 38, "width": 1512, "height": 851 }
}
| Field | Role |
|---|---|
x,y,width,height |
Last normal bounds. Never a work-area-filling rectangle. |
isMaximized |
Last Zoom / maximize chrome. |
isFullScreen |
Last macOS native fullscreen chrome. |
displayId |
Electron display id, used first when that screen is still connected. |
displayWorkArea |
Work-area rectangle so a reconnect that changed id still matches. |
Minimum usable size is 800×500. There is no maximum size. First-run
default size remains 1440×900, centered on the primary work area, then
inset if that default would itself fill the work area.
Restore algorithm#
- Read and migrate the file. Corrupt JSON, missing width/height,
NaN, or a postage-stamp size fall through to the centered default. - Resolve a live display: matching
displayId, then a work-area match (2px tolerance), then the legacy “bounds still overlap a work area” check. If none match — unplugged external monitor — use the primary work area and the centered default. Maximized / fullscreen flags are dropped on that fallback so the window opens usable. - If the saved rectangle fills a connected work area (legacy files),
treat it as maximized and replace the normal size with the inset
default. The
BrowserWindowis never constructed at work-area size. - Clamp the normal rectangle onto the chosen work area.
- Create the window at that normal size with
minWidth/minHeightset. Then callmaximize()and/orsetFullScreen(true)when those flags were saved.
Green Zoom therefore restores to the remembered normal size, not to a flush rectangle.
Maximize versus a filled work area#
If the user stretches the window to the work area without using Zoom, the session is persisted as maximized:
isMaximizedis set.- The last non-filling normal bounds are kept. Electron
getNormalBounds()is used when it is itself non-filling; otherwise the in-memory last-good rectangle, then the inset default. - Restore opens maximized. Zoom returns to that smaller normal size.
A rectangle is “filling” when it matches the display work area within
8 CSS pixels. The rule is applied on both save and restore so an old
{x:0,y:38,width:1512,height:851} file cannot come back as a flush
normal window.
Save timing#
The main window writes on resize, move, maximize, unmaximize,
enter-full-screen, leave-full-screen, and close. Move and resize
are debounced (300ms) so a drag does not flood the file. Chrome events
and close flush immediately.
While the window is maximized, fullscreen, or work-area-filling, only the flags and display identity update. The last good normal rectangle is left alone.
Display fallback#
Unplugging the saved display, or bounds that are off-screen, NaN, or
smaller than the minimum, opens a centered default on the primary work
area. The default is inset when 1440×900 would fill that work area, so
the fallback is always grabable.
Detached windows#
Torn-off windows stay ephemeral. They use a modest size
(min(1100, normal width) × min(720, normal height)), omit the
parent’s x/y, and never inherit maximize or fullscreen. Placement still
happens at the drop point through centerHiddenWindowOnCursor. Closing a
detached window does not write window-state.json.
Titlebar hit-testing#
The 40px desktop tab row remains -webkit-app-region: drag so empty
chrome moves the window. Three 5px no-drag strips sit on the top, left,
and right edges (and therefore the corners) so macOS can hit-test native
resize. Traffic lights start at {x:18,y:13}; tabs sit 6px below the
top. The inset does not cover those targets, the + button, or the
main-menu button (those already mark no-drag).
How this prevents the “cannot resize” class of bugs#
| Failure | Guard |
|---|---|
| Quit while Zoomed or work-area-filling | Restore creates a smaller normal window, then reapplies chrome. |
Legacy flush {x,y,width,height} file |
Migrated as maximized + inset default. |
| Unplugged display | Centered default on the primary work area. |
| Corrupt / tiny file | Minimum size + centered default. |
| Tab row swallows the top edge | 5px no-drag insets. |
| Closing a torn-off window | Detached windows do not persist. |
Implementation status#
Implemented: schema v2, legacy migration, restore-then-chrome sequence,
filled-work-area → maximized rule, display fallback, main-window
debounced persistence, detached ephemeral sizing, titlebar resize
insets, and the apps/desktop/scripts/check-window-state.js harness.
Desktop chrome color follows the resolved web theme (theme-chrome.js);
that is specified on unification-work.md. Window
bounds persistence does not store or restore a background color.