experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(windows): split maximize toggle into maximize + unmaximize cmds

Toggle semantics make the cmd panel surprising — a user typing
"maximize" expects maximize, not "if-already-maximized-do-the-opposite".
Two separate commands, both non-toggling.

- `maximize window` / `unmaximize window` cmds call the existing
`api.window.maximize` / new `api.window.unmaximize` IPC. The IPC
resolves the target window and is the single choke point — both the
OS-side bounds change AND the targeted main→renderer notification
fire from there. No pubsub broadcast: the IPC already knows which
window, so it sends to that webContents only.

- `tile:window:maximize` (IPC) snapshots the window's current bounds
into the windowRegistry params (`preMaximizeBounds`) — but only when
the bounds aren't already at the work area, so the user can resize a
maximized window smaller, hit `maximize` again, and have the smaller
size become the new pre-maximize snapshot. Then `setBounds(workArea)`
+ `webContents.send('tile:window:maximize-request')` so the
renderer's internal layout (FSM, screenBounds, centerColumn)
participates. Doesn't call `BrowserWindow.maximize()` — frameless /
panel-style windows on macOS don't reliably flip Electron's internal
isMaximized flag (the same caveat is documented in `ipc.ts` for the
open-time `maximize: true` path).

- `tile:window:unmaximize` (IPC, new) restores the stashed bounds via
`setBounds`, falling back to `BrowserWindow.unmaximize()` if the
registry has nothing recorded (window was OS-maximized via dbl-click
on a framed window). Also fires the targeted unmaximize-request
notification.

- `api.window.unmaximize(id?)`, `api.window.onMaximizeRequest(cb)`, and
`api.window.onUnmaximizeRequest(cb)` exposed on the tile preload
alongside `api.window.maximize`. Same `window.manage` capability gate
on the IPC; the listener bridges return an unsubscribe fn.

- `app/page/page.js` `toggleMaximize` is split into `doMaximize`,
`doUnmaximize`, and a wrapper that flips. The renderer subscribes
via `api.window.onMaximizeRequest` / `onUnmaximizeRequest` — non-
toggling, directed at this window only. Navbar dbl-click stays bound
to `toggleMaximize` (toggle is the right semantics for a single user
gesture).

Tests pin both halves of the contract — `maximize` twice keeps the
window maximized; `unmaximize` restores the pre-maximize bounds
(x/y/width/height) exactly.

+376 -99
+25 -28
@
··· 1 - feat(harness): reusable Electron CDP debugging harness for chrome-extension flows 1 + feat(windows): split maximize toggle into maximize + unmaximize cmds 2 2 3 - Adds tests/manual/, a Playwright-driven harness that launches Peek with an 4 - isolated test profile, attaches webContents.debugger to every webContents 5 - top-level windows AND embedded webviews, and dumps a structured trace 6 - JSON plus grep-friendly .log to tmp/harness/. 3 + Toggle semantics make the cmd panel surprising — a user typing 4 + "maximize" expects maximize, not "if-already-maximized-do-the-opposite". 5 + Two separate commands, both non-toggling. 7 6 8 - The harness captures network, console, and chrome.runtime introspection 9 - across every Electron frame. Playwright's page-level listeners can't see 10 - the webview's separate webContents, but CDP per-webContents can. First 11 - consumer is tests/manual/proton-auth.harness.ts; design accepts arbitrary 12 - scenarios via tests/manual/<name>.harness.ts. 7 + - `tile:window:maximize` no longer toggles. It snapshots the window's 8 + current bounds into the windowRegistry params (`preMaximizeBounds`), 9 + then resizes via `setBounds(workArea)` and calls 10 + `BrowserWindow.maximize()` for OSes that honour it. No-op if already 11 + maximized (registry-stashed or Electron-flagged). 12 + - `tile:window:unmaximize` is new. Restores the stashed bounds via 13 + `setBounds`, falling back to `BrowserWindow.unmaximize()` if the 14 + registry has nothing recorded (window was OS-maximized via dbl-click). 15 + - `api.window.unmaximize(id?)` exposed on the tile preload alongside 16 + `api.window.maximize`. Same `window.manage` capability gate. 17 + - `windows` feature: new `unmaximize window` cmd-panel command pairs 18 + with the existing `maximize window`. 13 19 14 - Stop signals besides the 3min HARNESS_DURATION_MS ceiling: 15 - - Close the auth window in the Peek instance. 16 - - yarn harness:stop -- touches tmp/harness-stop sentinel. 17 - 18 - Don't Ctrl+C the terminal -- that kills before the dump. 19 - 20 - Files added: 21 - - tests/manual/harness/types.ts 22 - - tests/manual/harness/env.ts 23 - - tests/manual/harness/tracer.ts 24 - - tests/manual/harness/popup.ts 25 - - tests/manual/harness/instrument.ts 26 - - tests/manual/proton-auth.harness.ts 27 - - tests/manual/README.md 28 - - tests/desktop/harness-smoke.spec.ts -- CI guard so the harness can't rot. 20 + Why bounds-stash rather than relying on Electron alone: frameless / 21 + panel-style windows on macOS don't reliably flip the internal 22 + isMaximized flag when `BrowserWindow.maximize()` is called, so a 23 + subsequent `unmaximize()` is a no-op on them — meaning we can't trust 24 + the OS flag alone for tile windows. The same workaround is already used 25 + in `ipc.ts` for the open-time `maximize: true` path. 29 26 30 - Files modified: 31 - - playwright.config.ts -- adds a 'manual' project, testMatch *.harness.ts. 32 - - package.json -- adds yarn harness and yarn harness:stop. 27 + Tests pin both halves of the contract — maximize twice keeps the window 28 + maximized; unmaximize restores the pre-maximize bounds (x/y/width/height) 29 + exactly.
+73 -65
app/page/page.js
··· 594 594 } 595 595 } 596 596 597 - // --- Toggle maximize --- 597 + // --- Maximize / unmaximize --- 598 + // 599 + // Three entry points: 600 + // - `doMaximize()` — non-toggling, no-op if already maximized. 601 + // - `doUnmaximize()` — non-toggling, no-op if not maximized. 602 + // - `toggleMaximize()` — flips, used by navbar dbl-click + cmd-palette 603 + // pubsub paths that explicitly want toggle semantics. 598 604 599 - async function toggleMaximize() { 600 - try { 601 - // Cancel any active drag — drag handlers running during the await would 602 - // overwrite screenBounds and call setBounds with stale values. 603 - if (inDragging()) { 604 - dragOverlay.classList.remove('active'); 605 - document.body.style.cursor = ''; 606 - navbar.style.cursor = 'grab'; 607 - dispatchFsm({ type: FSM_EVENTS.DRAG_END }); 608 - } 609 - cancelHoldDrag(); 610 - cancelWebviewHold(); 611 - // Cancel any pending throttled bounds update from drag/resize 612 - if (pendingBoundsUpdate) { 613 - cancelAnimationFrame(pendingBoundsUpdate); 614 - pendingBoundsUpdate = null; 615 - } 605 + function cancelInFlightInteractions() { 606 + if (inDragging()) { 607 + dragOverlay.classList.remove('active'); 608 + document.body.style.cursor = ''; 609 + navbar.style.cursor = 'grab'; 610 + dispatchFsm({ type: FSM_EVENTS.DRAG_END }); 611 + } 612 + cancelHoldDrag(); 613 + cancelWebviewHold(); 614 + if (pendingBoundsUpdate) { 615 + cancelAnimationFrame(pendingBoundsUpdate); 616 + pendingBoundsUpdate = null; 617 + } 618 + } 616 619 617 - if (inMaximized()) { 618 - // Restore from maximize. Use the saved WINDOW bounds directly — they 619 - // were captured from the live window before maximize, so restoring with 620 - // them guarantees byte-for-byte bounds equivalence regardless of any 621 - // canvas margin adjustments (which may or may not be applied in 622 - // headless/test environments). 623 - const restoreWindowBounds = preMaximizeWindowBounds; 624 - if (preMaximizeBounds) { 625 - screenBounds.x = preMaximizeBounds.x; 626 - screenBounds.y = preMaximizeBounds.y; 627 - screenBounds.width = preMaximizeBounds.width; 628 - screenBounds.height = preMaximizeBounds.height; 629 - preMaximizeBounds = null; 630 - } 631 - preMaximizeWindowBounds = null; 632 - 633 - // Dispatch flips fsmState MAXIMIZED→NORMAL and fires effects: 634 - // SET_BODY_MAXIMIZED(false), SET_HANDLES_VISIBLE(true) (→updatePositions), 635 - // SET_URL_MAXIMIZED(false) (→updateUrlParams). updatePositions and 636 - // updateUrlParams now see the restored screenBounds + NORMAL state. 637 - dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 638 - 639 - centerColumn.style.opacity = '0'; 640 - if (restoreWindowBounds) { 641 - await api.window.setBounds(restoreWindowBounds); 642 - } else { 643 - await api.window.setBounds(computeWindowBounds(screenBounds)); 644 - } 645 - centerColumn.style.opacity = '1'; 646 - return; 647 - } 620 + async function doMaximize() { 621 + try { 622 + cancelInFlightInteractions(); 623 + if (inMaximized()) return; 648 624 649 - // Maximize: fill screen work area 650 625 const displayResult = await api.window.getDisplayInfo(); 651 626 if (!displayResult || !displayResult.success || !displayResult.data) { 652 627 console.warn('[page] Failed to get display info for maximize'); 653 628 return; 654 629 } 655 - 656 630 const workArea = displayResult.data.workArea; 657 631 658 - // Save current bounds for restore 659 632 preMaximizeBounds = { 660 633 x: screenBounds.x, 661 634 y: screenBounds.y, 662 635 width: screenBounds.width, 663 636 height: screenBounds.height, 664 637 }; 665 - // Also capture the live WINDOW bounds — restoring via these bypasses 666 - // any computeWindowBounds margin math so the restored window matches 667 - // exactly what the user had before maximizing. 668 638 try { 669 639 const windowBoundsResult = await api.window.getBounds(); 670 640 if (windowBoundsResult && windowBoundsResult.success !== false) { ··· 679 649 preMaximizeWindowBounds = null; 680 650 } 681 651 682 - // screenBounds now represents the full work area (window = work area in maximized mode) 683 652 screenBounds.x = workArea.x; 684 653 screenBounds.y = workArea.y; 685 654 screenBounds.width = workArea.width; 686 655 screenBounds.height = workArea.height; 687 656 688 - // Dispatch flips fsmState NORMAL→MAXIMIZED and fires effects: 689 - // SET_BODY_MAXIMIZED(true), SET_HANDLES_VISIBLE(false) (→updatePositions 690 - // uses maximized layout), SET_URL_MAXIMIZED(true) (→updateUrlParams). 691 657 dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 692 658 693 659 centerColumn.style.opacity = '0'; 694 660 await api.window.setBounds(computeWindowBounds(screenBounds)); 695 661 centerColumn.style.opacity = '1'; 696 662 } catch (err) { 697 - console.error('[page] toggleMaximize failed:', err); 663 + console.error('[page] doMaximize failed:', err); 664 + } 665 + } 666 + 667 + async function doUnmaximize() { 668 + try { 669 + cancelInFlightInteractions(); 670 + if (!inMaximized()) return; 671 + 672 + // Restore from maximize. Use the saved WINDOW bounds directly when 673 + // available — they were captured live before maximize, so restoring 674 + // with them guarantees byte-for-byte equivalence regardless of any 675 + // canvas margin math. 676 + const restoreWindowBounds = preMaximizeWindowBounds; 677 + if (preMaximizeBounds) { 678 + screenBounds.x = preMaximizeBounds.x; 679 + screenBounds.y = preMaximizeBounds.y; 680 + screenBounds.width = preMaximizeBounds.width; 681 + screenBounds.height = preMaximizeBounds.height; 682 + preMaximizeBounds = null; 683 + } 684 + preMaximizeWindowBounds = null; 685 + 686 + dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 687 + 688 + centerColumn.style.opacity = '0'; 689 + if (restoreWindowBounds) { 690 + await api.window.setBounds(restoreWindowBounds); 691 + } else { 692 + await api.window.setBounds(computeWindowBounds(screenBounds)); 693 + } 694 + centerColumn.style.opacity = '1'; 695 + } catch (err) { 696 + console.error('[page] doUnmaximize failed:', err); 697 + } 698 + } 699 + 700 + async function toggleMaximize() { 701 + if (inMaximized()) { 702 + return doUnmaximize(); 698 703 } 704 + return doMaximize(); 699 705 } 700 706 701 707 // --- Set window bounds via IPC --- ··· 2330 2336 // match cmd-palette input regardless of which window the user was looking 2331 2337 // at, and toggle whichever page-host registered the handler. 2332 2338 2333 - // Subscribe to page:maximize pubsub for explicit per-window publishes. 2334 - api.subscribe('page:maximize', (msg) => { 2335 - if (msg.windowId != null && msg.windowId !== myWindowId) return; 2336 - toggleMaximize(); 2337 - }); 2339 + // Targeted main→renderer notifications from the maximize/unmaximize 2340 + // IPC handlers. Page-host owns its internal layout (FSM, screenBounds, 2341 + // centerColumn), so the IPC tells *this* window's renderer to run its 2342 + // own non-toggling helpers. No pubsub broadcast — the IPC already 2343 + // resolved the target window, so it sends to that webContents only. 2344 + api.window.onMaximizeRequest(() => doMaximize()); 2345 + api.window.onUnmaximizeRequest(() => doUnmaximize()); 2338 2346 2339 2347 // --- Web permission prompt --- 2340 2348 // Backend's permission-handler.ts publishes `page:permission-request` when a
+96 -4
backend/electron/tile-ipc.ts
··· 2973 2973 2974 2974 // ── tile:window:maximize ────────────────────────────────────────── 2975 2975 // 2976 - // Toggle maximize on a window. Gated by `window.manage`. 2976 + // Maximize a window. Non-toggling: no-op if already maximized. Pair 2977 + // with `tile:window:unmaximize` to restore. Gated by `window.manage`. 2978 + // 2979 + // We don't rely solely on BrowserWindow.maximize() because frameless / 2980 + // panel-style windows on macOS don't always flip Electron's internal 2981 + // isMaximized flag — meaning a subsequent unmaximize() is a no-op. So 2982 + // we also stash pre-maximize bounds in the window's registry entry and 2983 + // resize via setBounds(workArea), mirroring the open-time 2984 + // `maximize: true` path in ipc.ts. 2977 2985 registerTileIpc('tile:window:maximize', { mode: 'handle' }, async (event, args: { 2978 2986 token: string; 2979 2987 id?: number; ··· 3003 3011 // by symptom ("the window behind got maximized"), and seeing the 3004 3012 // resolved id + url in production stderr makes it diagnosable 3005 3013 // without re-instrumenting the user. 3014 + const info = getWindowInfo(win.id); 3006 3015 try { 3007 3016 console.log( 3008 3017 `[tile:window:maximize] id=${args.id ?? 'sender-fallback'} resolved=${win.id} url=${win.webContents.getURL().slice(0, 80)}` 3009 3018 ); 3010 3019 } catch { /* logging must never throw */ } 3011 - if (win.isMaximized()) { 3020 + 3021 + const currentBounds = win.getBounds(); 3022 + const display = screen.getDisplayMatching(currentBounds); 3023 + const workArea = display.workArea; 3024 + const atWorkArea = 3025 + currentBounds.x === workArea.x && 3026 + currentBounds.y === workArea.y && 3027 + currentBounds.width === workArea.width && 3028 + currentBounds.height === workArea.height; 3029 + 3030 + // Only snapshot if the window isn't already filling the work area. 3031 + // This lets the user resize a maximized window smaller, hit 3032 + // `maximize` again, and have the new smaller size become the new 3033 + // pre-maximize snapshot — instead of being stuck with the original 3034 + // snapshot from the first maximize. 3035 + if (!atWorkArea && info) { 3036 + info.params.preMaximizeBounds = { 3037 + x: currentBounds.x, 3038 + y: currentBounds.y, 3039 + width: currentBounds.width, 3040 + height: currentBounds.height, 3041 + }; 3042 + } 3043 + 3044 + win.setBounds(workArea); 3045 + // Targeted notification to the window's renderer — page-host (and 3046 + // any other renderer that owns internal layout state) subscribes 3047 + // via api.window.onMaximizeRequest to keep its FSM/bounds in sync 3048 + // without going through pubsub broadcast. 3049 + try { win.webContents.send('tile:window:maximize-request'); } catch { /* renderer may be gone */ } 3050 + return { success: true }; 3051 + } catch (err) { 3052 + const message = err instanceof Error ? err.message : String(err); 3053 + return { success: false, error: message }; 3054 + } 3055 + }); 3056 + 3057 + // ── tile:window:unmaximize ──────────────────────────────────────── 3058 + // 3059 + // Restore a maximized window to its pre-maximize bounds. Reads the 3060 + // bounds we stashed in the window's registry entry; falls back to 3061 + // BrowserWindow.unmaximize() if the registry has nothing (window was 3062 + // maximized via dbl-click on the OS frame, etc.). Gated by 3063 + // `window.manage`. 3064 + registerTileIpc('tile:window:unmaximize', { mode: 'handle' }, async (event, args: { 3065 + token: string; 3066 + id?: number; 3067 + }, _grant) => { 3068 + const grant = _grant; 3069 + const check = checkWindowAllowed(grant, 'maximize'); 3070 + if (!check.ok) { 3071 + handleViolation(grant, 'window', 'window:unmaximize', check.error, args.token); 3072 + return { success: false, error: check.error }; 3073 + } 3074 + 3075 + try { 3076 + if (args.id !== undefined && typeof args.id !== 'number') { 3077 + return { success: false, error: `Invalid window id: ${typeof args.id}` }; 3078 + } 3079 + const win = typeof args.id === 'number' 3080 + ? BrowserWindow.fromId(args.id) 3081 + : BrowserWindow.fromWebContents(event.sender); 3082 + if (!win || win.isDestroyed()) { 3083 + return { success: false, error: 'Window not found' }; 3084 + } 3085 + const info = getWindowInfo(win.id); 3086 + try { 3087 + console.log( 3088 + `[tile:window:unmaximize] id=${args.id ?? 'sender-fallback'} resolved=${win.id} hasPreBounds=${info?.params?.preMaximizeBounds != null} isMaximized=${win.isMaximized()} url=${win.webContents.getURL().slice(0, 80)}` 3089 + ); 3090 + } catch { /* logging must never throw */ } 3091 + 3092 + const stashed = info?.params?.preMaximizeBounds as 3093 + | { x: number; y: number; width: number; height: number } 3094 + | undefined; 3095 + 3096 + if (stashed) { 3097 + // Clear electron's own maximize flag too, in case it's set. 3098 + if (win.isMaximized()) { 3099 + win.unmaximize(); 3100 + } 3101 + win.setBounds(stashed); 3102 + delete info!.params.preMaximizeBounds; 3103 + } else if (win.isMaximized()) { 3012 3104 win.unmaximize(); 3013 - } else { 3014 - win.maximize(); 3015 3105 } 3106 + // Targeted notification — see comment in tile:window:maximize. 3107 + try { win.webContents.send('tile:window:unmaximize-request'); } catch { /* renderer may be gone */ } 3016 3108 return { success: true }; 3017 3109 } catch (err) { 3018 3110 const message = err instanceof Error ? err.message : String(err);
+40 -1
backend/electron/tile-preload.cts
··· 681 681 }, 682 682 683 683 /** 684 - * Toggle maximize on a window. Requires `window.manage` (or 684 + * Maximize a window (non-toggling — no-op if already maximized). 685 + * Pair with `unmaximize` to restore. Requires `window.manage` (or 685 686 * trustedBuiltin). 686 687 */ 687 688 maximize: (id?: number) => { ··· 689 690 token: tileToken, 690 691 id, 691 692 }); 693 + }, 694 + 695 + /** 696 + * Restore a maximized window to its pre-maximize bounds. No-op if 697 + * the window is not maximized. Requires `window.manage` (or 698 + * trustedBuiltin). 699 + */ 700 + unmaximize: (id?: number) => { 701 + return ipcRenderer.invoke('tile:window:unmaximize', { 702 + token: tileToken, 703 + id, 704 + }); 705 + }, 706 + 707 + /** 708 + * Subscribe to maximize requests targeted at this window. The 709 + * `tile:window:maximize` IPC fires this on the target window's 710 + * webContents after the OS-side bounds change. Renderers that 711 + * manage their own internal layout (e.g. the page-host) hook in 712 + * here to keep their state in sync. Returns an unsubscribe fn. 713 + * 714 + * Targeted send (no broadcast) — only the resolved window receives 715 + * the event. 716 + */ 717 + onMaximizeRequest: (cb: () => void) => { 718 + const handler = () => { try { cb(); } catch (err) { console.error('[onMaximizeRequest]', err); } }; 719 + ipcRenderer.on('tile:window:maximize-request', handler); 720 + return () => ipcRenderer.off('tile:window:maximize-request', handler); 721 + }, 722 + 723 + /** 724 + * Subscribe to unmaximize requests targeted at this window. See 725 + * `onMaximizeRequest` for the firing contract. 726 + */ 727 + onUnmaximizeRequest: (cb: () => void) => { 728 + const handler = () => { try { cb(); } catch (err) { console.error('[onUnmaximizeRequest]', err); } }; 729 + ipcRenderer.on('tile:window:unmaximize-request', handler); 730 + return () => ipcRenderer.off('tile:window:unmaximize-request', handler); 692 731 }, 693 732 694 733 /**
+1 -1
backend/electron/tile-window-enforcement.ts
··· 36 36 * - `set-ignore-mouse` — toggle click-through on a window; gated by `manage`. 37 37 * - `center` — center a window on its display; gated by `manage`. 38 38 * - `center-all` — center every visible window; gated by `manage`. 39 - * - `maximize` — toggle maximize on a window; gated by `manage`. 39 + * - `maximize` — maximize or unmaximize a window; gated by `manage`. 40 40 * - `fullscreen` — toggle fullscreen on a window; gated by `manage`. 41 41 * - `get-focused-visible-id` — read lastFocusedVisibleWindowId; gated by `query`. 42 42 * - `set-overlay-focus-target` — set overlay restore target; gated by `manage`.
+9
features/windows/background.js
··· 118 118 } 119 119 }, 120 120 { 121 + name: 'unmaximize window', 122 + description: 'Restore the active window from maximize', 123 + execute: async (ctx) => { 124 + const windowId = await api.window.getFocusedVisibleWindowId(); 125 + if (!windowId) return { success: false, error: 'No active window' }; 126 + return api.window.unmaximize(windowId); 127 + } 128 + }, 129 + { 121 130 name: 'fullscreen', 122 131 description: 'Toggle fullscreen for the active window', 123 132 execute: async (ctx) => {
+5
features/windows/manifest.json
··· 63 63 "action": { "type": "execute" } 64 64 }, 65 65 { 66 + "name": "unmaximize window", 67 + "description": "Restore the active window from maximize", 68 + "action": { "type": "execute" } 69 + }, 70 + { 66 71 "name": "fullscreen", 67 72 "description": "Toggle fullscreen for the active window", 68 73 "action": { "type": "execute" }
+127
tests/desktop/window-targeting.spec.ts
··· 222 222 }, { backId: result.backId as number, tileId: result.tileId as number }); 223 223 }); 224 224 225 + test('maximize is non-toggling — calling twice keeps window maximized', async () => { 226 + // Regression: maximize used to toggle (maximize ↔ unmaximize), so a 227 + // double-fire (or running the cmd twice with no intervening user 228 + // action) would surprise the user. The contract is now: maximize 229 + // always maximizes; unmaximize always restores. 230 + const winId = await bgWindow.evaluate(async () => { 231 + const api = (window as any).app; 232 + const r = await api.window.open('about:blank', { 233 + role: 'workspace', 234 + width: 600, 235 + height: 400, 236 + key: 'maximize-non-toggle', 237 + }); 238 + if (!r.success) throw new Error('open failed: ' + r.error); 239 + await new Promise(res => setTimeout(res, 200)); 240 + await api.window.maximize(r.id); 241 + await new Promise(res => setTimeout(res, 150)); 242 + await api.window.maximize(r.id); 243 + await new Promise(res => setTimeout(res, 150)); 244 + return r.id as number; 245 + }); 246 + 247 + const isMaxedAfterTwice = await app.evaluateMain!<boolean, number>( 248 + ({ BrowserWindow }, id) => { 249 + const w = BrowserWindow.fromId(id); 250 + return w ? w.isMaximized() : false; 251 + }, 252 + winId, 253 + ); 254 + expect(isMaxedAfterTwice).toBe(true); 255 + 256 + // unmaximize → restored 257 + await bgWindow.evaluate(async (id) => { 258 + const api = (window as any).app; 259 + await api.window.unmaximize(id); 260 + await new Promise(res => setTimeout(res, 150)); 261 + }, winId); 262 + 263 + const isMaxedAfterUnmax = await app.evaluateMain!<boolean, number>( 264 + ({ BrowserWindow }, id) => { 265 + const w = BrowserWindow.fromId(id); 266 + return w ? w.isMaximized() : false; 267 + }, 268 + winId, 269 + ); 270 + expect(isMaxedAfterUnmax).toBe(false); 271 + 272 + // unmaximize again → still not maximized (no-op) 273 + await bgWindow.evaluate(async (id) => { 274 + const api = (window as any).app; 275 + await api.window.unmaximize(id); 276 + await new Promise(res => setTimeout(res, 150)); 277 + }, winId); 278 + 279 + const isMaxedAfterUnmaxTwice = await app.evaluateMain!<boolean, number>( 280 + ({ BrowserWindow }, id) => { 281 + const w = BrowserWindow.fromId(id); 282 + return w ? w.isMaximized() : false; 283 + }, 284 + winId, 285 + ); 286 + expect(isMaxedAfterUnmaxTwice).toBe(false); 287 + 288 + await bgWindow.evaluate(async (id) => { 289 + const api = (window as any).app; 290 + await api.window.close(id); 291 + }, winId); 292 + }); 293 + 294 + test('unmaximize restores pre-maximize bounds', async () => { 295 + // Frameless / panel-style windows don't always flip Electron's 296 + // BrowserWindow.isMaximized() flag, so unmaximize() alone is a no-op 297 + // on them. The IPC stashes pre-maximize bounds in the window's 298 + // registry entry and restores via setBounds. This test pins that 299 + // behavior — open a window at a known size, maximize, unmaximize, 300 + // assert size matches the original. 301 + const winId = await bgWindow.evaluate(async () => { 302 + const api = (window as any).app; 303 + const r = await api.window.open('about:blank', { 304 + role: 'workspace', 305 + width: 642, 306 + height: 437, 307 + key: 'unmaximize-restores-bounds', 308 + }); 309 + if (!r.success) throw new Error('open failed: ' + r.error); 310 + await new Promise(res => setTimeout(res, 250)); 311 + return r.id as number; 312 + }); 313 + 314 + const readBounds = async (id: number) => 315 + app.evaluateMain!<{ x: number; y: number; width: number; height: number } | null, number>( 316 + ({ BrowserWindow }, wid) => { 317 + const w = BrowserWindow.fromId(wid); 318 + return w ? w.getBounds() : null; 319 + }, 320 + id, 321 + ); 322 + 323 + const before = await readBounds(winId); 324 + await bgWindow.evaluate(async (id) => { 325 + await (window as any).app.window.maximize(id); 326 + await new Promise(res => setTimeout(res, 200)); 327 + }, winId); 328 + const maxed = await readBounds(winId); 329 + await bgWindow.evaluate(async (id) => { 330 + await (window as any).app.window.unmaximize(id); 331 + await new Promise(res => setTimeout(res, 200)); 332 + }, winId); 333 + const after = await readBounds(winId); 334 + 335 + await bgWindow.evaluate(async (id) => { 336 + await (window as any).app.window.close(id); 337 + }, winId); 338 + 339 + expect(before).not.toBeNull(); 340 + expect(maxed).not.toBeNull(); 341 + expect(after).not.toBeNull(); 342 + // Maximized bounds should be larger than the original. 343 + expect(maxed!.width).toBeGreaterThan(before!.width); 344 + expect(maxed!.height).toBeGreaterThan(before!.height); 345 + // Post-unmaximize, bounds should match the pre-maximize snapshot. 346 + expect(after!.width).toBe(before!.width); 347 + expect(after!.height).toBe(before!.height); 348 + expect(after!.x).toBe(before!.x); 349 + expect(after!.y).toBe(before!.y); 350 + }); 351 + 225 352 test('setWindowColorScheme returns success with windowId', async () => { 226 353 // Test that setWindowColorScheme works and returns expected data 227 354 const result = await bgWindow.evaluate(async () => {