experiments in a post-browser web
10
fork

Configure Feed

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

refactor(window-presenter): single source of truth for user-facing window state

Adds a WindowPresenter that resolves a window's user-facing
appearance — address (page-host inner URL or host URL), canvas-aware
bounds, maximize state, favicon, thumbnail — from authoritative
in-memory state (windowRegistry.info.params, BrowserWindow), without
any caller having to know about the page-host shell URL or its
search-param bootstrap.

Why:
- Session save was parsing peek://app/page/?url=...&x=...&maximized=1
search params to recover the inner URL and canvas bounds. Each
consumer (session save, reopen-last-closed, tile:window:list) was
rediscovering the same parsing rules and drifting apart.
- The page-host already maintained the inner URL on
info.params.currentUrl (set by main.ts's did-attach-webview on
every navigation). The URL-search-param dance was a vestigial
source of truth.

Changes:
- backend/electron/window-address.ts (new): pure
resolveWindowAddress(hostUrl, params) helper. No Electron deps so
it is unit-testable under ELECTRON_RUN_AS_NODE.
- backend/electron/window-presenter.ts (new): WindowPresentation
interface + getWindowPresentation(windowId). One entry point;
consumers do not touch URL parsing.
- session.ts: deleted extractRealUrl / extractCanvasBounds; reads
presenter. Adds overlay carve-out — windows hidden by an active
overlay (Windows switcher, etc.) are still saved, while overlay
descriptors themselves are skipped at save AND restore.
- main.ts close handler: captures lastPresentation pre-destruction
so reopen-last-closed gets canvas-aware bounds and maximize state
without re-parsing the host URL.
- tile-ipc.ts: tile:window:list returns presenter.address as the
url field.
- ipc.ts, tile-api.d.ts, tile-preload.cts, app/page/page.js: thread
through info.params updates so currentUrl stays authoritative.
- features/windows: shows favicon + presenter-resolved title/url.
- session.test.ts: regression tests for overlay-hidden window save,
overlay descriptor skip at save AND restore.
- window-address.test.ts: 8 unit tests for the resolver.
- tests/desktop/session-restore-page-host.spec.ts: integration test
for the cmd+n -> type URL -> quit -> restart restore path.

+849 -181
+76 -14
app/page/page.js
··· 129 129 // the maximized layout — so the page-host FSM boots straight into 130 130 // MAXIMIZED via INIT_COMPLETE without needing to call setBounds. 131 131 const initialMaximized = params.get('maximized') === '1'; 132 + // Pre-maximize bounds, threaded across save/restore so unmaximize can 133 + // restore to the size the window had before being maximized — even when 134 + // the page-host boots into MAXIMIZED via session restore (in which case 135 + // doMaximize never ran, so renderer module state never captured them). 136 + // Encoded as 4 primitive URL params (preX/preY/preWidth/preHeight) so 137 + // they survive sanitizeParams in session.ts; written by doMaximize and 138 + // cleared by doUnmaximize. 139 + const initialPreMaxX = parseInt(params.get('preX')); 140 + const initialPreMaxY = parseInt(params.get('preY')); 141 + const initialPreMaxW = parseInt(params.get('preWidth')); 142 + const initialPreMaxH = parseInt(params.get('preHeight')); 143 + const hasInitialPreMaxBounds = 144 + Number.isFinite(initialPreMaxX) && Number.isFinite(initialPreMaxY) 145 + && Number.isFinite(initialPreMaxW) && Number.isFinite(initialPreMaxH); 132 146 133 147 if (!targetUrl) { 134 148 console.error('[page] No URL provided'); ··· 158 172 // how the window was originally sized — e.g., in headless test mode where the 159 173 // canvas margin adjustment in ipc.ts window-open is skipped). 160 174 let preMaximizeWindowBounds = null; 175 + 176 + // Restore pre-maximize bounds from URL when the page-host boots directly 177 + // into MAXIMIZED via session restore / undo-close. Without this, doMaximize 178 + // never runs, so module-state preMaximizeBounds stays null and unmaximize 179 + // has nothing to restore to — falls through to computeWindowBounds(workArea) 180 + // and produces the "slightly smaller than maximized" effect. 181 + if (initialMaximized && hasInitialPreMaxBounds) { 182 + preMaximizeBounds = { 183 + x: initialPreMaxX, 184 + y: initialPreMaxY, 185 + width: initialPreMaxW, 186 + height: initialPreMaxH, 187 + }; 188 + // No raw window bounds available — doUnmaximize will fall through to 189 + // computeWindowBounds(preMaximizeBounds) which adds canvas margins; that's 190 + // the correct round-trip for screen-coordinate (canvas) bounds. 191 + preMaximizeWindowBounds = null; 192 + } 161 193 162 194 // Apply FSM effects to the DOM. fsmState is already the source of truth by 163 195 // the time effects run (dispatchFsm assigns next.state before iterating); ··· 328 360 // --- Position all elements relative to the window --- 329 361 330 362 function updateUrlParams() { 331 - // Keep the URL search params in sync with current screen bounds so that 332 - // session save (extractCanvasBounds) reads the latest position/size. 363 + // Two writes: 364 + // 1. IPC to main → updates info.params (registry). This is the 365 + // authoritative source for session save and close-undo at quit 366 + // time. info.params holds primitives so it survives sanitizeParams. 367 + // 2. URL search params (history.replaceState). Renderer-side mirror 368 + // for observability — visible in dev tools, in window.location, 369 + // and to tests that watch URL state. NOT read by main at save 370 + // time (that path now reads from info.params), so URL drift 371 + // doesn't produce persistence bugs. 372 + const maxed = inMaximized(); 373 + const x = Math.round(screenBounds.x); 374 + const y = Math.round(screenBounds.y); 375 + const w = Math.round(screenBounds.width); 376 + const h = Math.round(screenBounds.height); 377 + const payload = { x, y, width: w, height: h, maximized: maxed }; 378 + if (maxed && preMaximizeBounds) { 379 + payload.preMaxX = Math.round(preMaximizeBounds.x); 380 + payload.preMaxY = Math.round(preMaximizeBounds.y); 381 + payload.preMaxWidth = Math.round(preMaximizeBounds.width); 382 + payload.preMaxHeight = Math.round(preMaximizeBounds.height); 383 + } 384 + try { 385 + api.window.updateCanvasState(payload).catch((err) => { 386 + DEBUG && console.log('[page] updateCanvasState failed:', err); 387 + }); 388 + } catch (e) { 389 + DEBUG && console.log('[page] updateCanvasState threw:', e); 390 + } 333 391 try { 334 392 const url = new URL(window.location.href); 335 - url.searchParams.set('x', String(Math.round(screenBounds.x))); 336 - url.searchParams.set('y', String(Math.round(screenBounds.y))); 337 - url.searchParams.set('width', String(Math.round(screenBounds.width))); 338 - url.searchParams.set('height', String(Math.round(screenBounds.height))); 339 - // Carry the maximized flag so undo-close (main.ts) can fall back to the 340 - // raw OS window bounds — when maximized, screenBounds equals the work 341 - // area (window == webview) and re-adding canvas margins on reopen would 342 - // overshoot the display. 343 - if (inMaximized()) { 344 - url.searchParams.set('maximized', '1'); 393 + url.searchParams.set('x', String(x)); 394 + url.searchParams.set('y', String(y)); 395 + url.searchParams.set('width', String(w)); 396 + url.searchParams.set('height', String(h)); 397 + if (maxed) url.searchParams.set('maximized', '1'); 398 + else url.searchParams.delete('maximized'); 399 + if (maxed && preMaximizeBounds) { 400 + url.searchParams.set('preX', String(payload.preMaxX)); 401 + url.searchParams.set('preY', String(payload.preMaxY)); 402 + url.searchParams.set('preWidth', String(payload.preMaxWidth)); 403 + url.searchParams.set('preHeight', String(payload.preMaxHeight)); 345 404 } else { 346 - url.searchParams.delete('maximized'); 405 + url.searchParams.delete('preX'); 406 + url.searchParams.delete('preY'); 407 + url.searchParams.delete('preWidth'); 408 + url.searchParams.delete('preHeight'); 347 409 } 348 410 history.replaceState(null, '', url.toString()); 349 411 } catch (e) { 350 - DEBUG && console.log('[page] Failed to update URL params:', e); 412 + DEBUG && console.log('[page] URL mirror update failed:', e); 351 413 } 352 414 } 353 415
+56 -10
backend/electron/ipc.ts
··· 451 451 // Restore maximize state so the page-host FSM boots into MAXIMIZED. 452 452 if (entry.maximized) options.maximized = true; 453 453 454 + // Pre-maximize bounds: thread through so the reopened page-host can 455 + // populate its renderer-side preMaximizeBounds at INIT — letting the 456 + // user unmaximize back to the size they had before the original 457 + // maximize, even after a close+reopen cycle. 458 + if (entry.preMaximizeBounds) { 459 + options.preMaxX = entry.preMaximizeBounds.x; 460 + options.preMaxY = entry.preMaximizeBounds.y; 461 + options.preMaxWidth = entry.preMaximizeBounds.width; 462 + options.preMaxHeight = entry.preMaximizeBounds.height; 463 + } 464 + 454 465 // Publish event for the background window to pick up and open 455 466 publish(getSystemAddress(), 'window:reopen-request', { 456 467 url: entry.url, ··· 915 926 // the webview coordinates then jumps to the correct position. 916 927 // Save the original webview screen coordinates for URL params. 917 928 let canvasWebviewBounds: { x: number; y: number; width: number; height: number } | null = null; 918 - if (useCanvas && !isHeadless()) { 929 + if (useCanvas) { 919 930 const CANVAS_MARGIN = 8; 920 931 const CANVAS_TRIGGER_ZONE = 8; 921 932 const CANVAS_NAVBAR_HEIGHT = 36; // Always reserve space for navbar (avoids resize jumps) ··· 936 947 const display = screen.getDisplayNearestPoint({ x: winOptions.x as number, y: winOptions.y as number }); 937 948 const wa = display.workArea; 938 949 canvasWebviewBounds = { x: wa.x, y: wa.y, width: wa.width, height: wa.height }; 939 - winOptions.x = wa.x; 940 - winOptions.y = wa.y; 941 - winOptions.width = wa.width; 942 - winOptions.height = wa.height; 950 + if (!isHeadless()) { 951 + winOptions.x = wa.x; 952 + winOptions.y = wa.y; 953 + winOptions.width = wa.width; 954 + winOptions.height = wa.height; 955 + } 943 956 } else { 944 957 // winOptions.x/y/width/height are currently the webview screen coordinates 945 958 canvasWebviewBounds = { ··· 948 961 width: winOptions.width as number, 949 962 height: winOptions.height as number, 950 963 }; 951 - winOptions.x = canvasWebviewBounds.x - CANVAS_MARGIN; 952 - winOptions.y = canvasWebviewBounds.y - CANVAS_TRIGGER_ZONE - CANVAS_NAVBAR_HEIGHT; 953 - winOptions.width = canvasWebviewBounds.width + CANVAS_MARGIN * 2; 954 - winOptions.height = canvasWebviewBounds.height + CANVAS_TRIGGER_ZONE + CANVAS_MARGIN + CANVAS_NAVBAR_HEIGHT; 964 + if (!isHeadless()) { 965 + winOptions.x = canvasWebviewBounds.x - CANVAS_MARGIN; 966 + winOptions.y = canvasWebviewBounds.y - CANVAS_TRIGGER_ZONE - CANVAS_NAVBAR_HEIGHT; 967 + winOptions.width = canvasWebviewBounds.width + CANVAS_MARGIN * 2; 968 + winOptions.height = canvasWebviewBounds.height + CANVAS_TRIGGER_ZONE + CANVAS_MARGIN + CANVAS_NAVBAR_HEIGHT; 969 + } 955 970 } 956 971 } 957 972 ··· 1116 1131 // would lose the body class / handles / URL param sync. 1117 1132 // See docs/page-host-state-machine.md. 1118 1133 if (options.maximized === true) pageParams.set('maximized', '1'); 1134 + // Pre-maximize bounds: thread through so the page-host can populate 1135 + // its renderer-side preMaximizeBounds at INIT, allowing unmaximize 1136 + // (any time after restore) to restore to the size before the 1137 + // original maximize. Without this, doMaximize never ran and the 1138 + // renderer has no bounds to unmaximize to. 1139 + if (options.maximized === true) { 1140 + const px = options.preMaxX, py = options.preMaxY, 1141 + pw = options.preMaxWidth, ph = options.preMaxHeight; 1142 + if (typeof px === 'number' && typeof py === 'number' 1143 + && typeof pw === 'number' && typeof ph === 'number') { 1144 + pageParams.set('preX', String(px)); 1145 + pageParams.set('preY', String(py)); 1146 + pageParams.set('preWidth', String(pw)); 1147 + pageParams.set('preHeight', String(ph)); 1148 + } 1149 + } 1119 1150 loadUrl = `peek://app/page/index.html?${pageParams.toString()}`; 1120 1151 DEBUG && console.log('Routing web page through peek://app/page:', url, '->', loadUrl); 1121 1152 ··· 1166 1197 // `placement` was derived earlier (in Group B fresh-open 1167 1198 // positioning) and the same value is recorded on the registry 1168 1199 // here so reuse paths + display-watcher can consume it. 1169 - const windowParams = { 1200 + const windowParams: Record<string, unknown> = { 1170 1201 ...options, 1171 1202 address: url, 1172 1203 transient: isTransient, ··· 1174 1205 role, 1175 1206 placement, 1176 1207 }; 1208 + // Seed canvas state (x/y/width/height + maximized) on the registry 1209 + // entry from the resolved canvas webview bounds. This is the source 1210 + // of truth for session save and the close-undo handler — the page-host 1211 + // renderer keeps it current via tile:window:update-canvas-state IPC. 1212 + // Seeding here ensures the values are correct from the moment the 1213 + // window registers, even before the renderer fires its first state 1214 + // update (which only happens on a transition: resize end, drag end, 1215 + // maximize, unmaximize). 1216 + if (useCanvas && canvasWebviewBounds) { 1217 + windowParams.x = canvasWebviewBounds.x; 1218 + windowParams.y = canvasWebviewBounds.y; 1219 + windowParams.width = canvasWebviewBounds.width; 1220 + windowParams.height = canvasWebviewBounds.height; 1221 + windowParams.maximized = options.maximized === true; 1222 + } 1177 1223 console.log('Adding window to manager:', win.id, 'escapeMode:', windowParams.escapeMode, 'modal:', windowParams.modal, 'keepLive:', windowParams.keepLive, 'transient:', isTransient, 'placement:', placement); 1178 1224 registerWindow(win.id, msg.source, windowParams); 1179 1225 const coordinator = getIzuiCoordinator();
+31 -42
backend/electron/main.ts
··· 33 33 import { getIzuiCoordinator } from './izui-state.js'; 34 34 import { clearLastFocusedVisibleWindowId, trackOnWindowFocus } from './ipc.js'; 35 35 import type { Placement } from './window-placement.js'; 36 + import { getWindowPresentation } from './window-presenter.js'; 36 37 37 38 // Configuration 38 39 export interface AppConfig { ··· 101 102 scrollX?: number; // Scroll position at time of close 102 103 scrollY?: number; 103 104 maximized?: boolean; // Page-host was maximized at close — restore directly into MAXIMIZED on reopen 105 + // Pre-maximize bounds: stored alongside `maximized` so reopen-after-close 106 + // can hand them to the page-host, letting unmaximize after reopen restore 107 + // to the size before the original maximize. Read from the page-host URL 108 + // params (preX/preY/preWidth/preHeight) at close time. 109 + preMaximizeBounds?: { x: number; y: number; width: number; height: number }; 104 110 timestamp: number; 105 111 } 106 112 ··· 385 391 app.on('browser-window-created', (_, window) => { 386 392 const windowId = window.id; 387 393 388 - // Capture window bounds + page-host URL before the window is destroyed 389 - // (for reopen-last-closed). The page-host keeps URL params in sync with 390 - // its true webview screenBounds (see app/page/page.js updateUrlParams) — 391 - // those are the authoritative size for the next open, since window 392 - // bounds also include side-panel extraWidth (entities/notes/etc). 394 + // Capture the window's canonical presentation before destruction so the 395 + // 'closed' handler can persist correct bounds, address, and maximize 396 + // state for reopen-last-closed. The presenter encapsulates the canvas / 397 + // generic split — main.ts no longer parses the page-host shell URL. 393 398 let lastBounds: { x: number; y: number; width: number; height: number } | null = null; 394 399 let lastHostUrl: string | null = null; 400 + let lastPresentation: import('./window-presenter.js').WindowPresentation | null = null; 395 401 window.on('close', () => { 396 402 try { 397 403 if (!window.isDestroyed()) { 398 404 lastBounds = window.getBounds(); 399 405 lastHostUrl = window.webContents.getURL(); 406 + lastPresentation = getWindowPresentation(windowId); 400 407 } 401 408 } catch { 402 409 // Ignore errors during shutdown ··· 532 539 // Context may be cleaned up already during shutdown 533 540 } 534 541 535 - // For canvas pages, the page-host URL params are the authoritative 536 - // webview screen bounds — they exclude side-panel extraWidth (which 537 - // varies with whether entities/notes/etc are open). Falling back to 538 - // window-bounds-minus-fixed-margins inflated saveBounds whenever a 539 - // panel was open, so reopen produced a wider window each cycle. 540 - // 541 - // Exception: when the page-host is in maximized mode, screenBounds 542 - // equals the display work area (window == webview, no canvas chrome 543 - // applied) — using URL params here would cause reopen's canvas 544 - // margin add to overshoot the display. Fall back to fixed-margin 545 - // subtraction in that case so the round-trip is exact. 546 - let saveBounds = lastBounds; 547 - let saveMaximized = false; 548 - if (isWebUrl && lastHostUrl && lastHostUrl.startsWith('peek://app/page/')) { 549 - try { 550 - const parsed = new URL(lastHostUrl); 551 - saveMaximized = parsed.searchParams.get('maximized') === '1'; 552 - if (!saveMaximized) { 553 - const px = parseInt(parsed.searchParams.get('x') || ''); 554 - const py = parseInt(parsed.searchParams.get('y') || ''); 555 - const pw = parseInt(parsed.searchParams.get('width') || ''); 556 - const ph = parseInt(parsed.searchParams.get('height') || ''); 557 - if (Number.isFinite(px) && Number.isFinite(py) && Number.isFinite(pw) && Number.isFinite(ph)) { 558 - saveBounds = { x: px, y: py, width: pw, height: ph }; 559 - } 560 - } 561 - } catch { 562 - // URL parse failed — fall through to bounds fallback below 563 - } 564 - } 565 - // Fallback when no URL params (non-canvas, or page-host hadn't 566 - // updated params yet, or the window was maximized): subtract the 567 - // fixed canvas chrome from window bounds. Note: still over-counts 568 - // when panels were open in non-maximized mode, but only reached 569 - // when URL params are unavailable or unreliable. 570 - if (saveBounds === lastBounds && lastBounds && isWebUrl) { 542 + // Pull bounds + maximize state from the presentation captured 543 + // pre-destruction. The presenter already returns canvas-aware 544 + // bounds for page-hosts (info.params x/y/width/height) and 545 + // BrowserWindow bounds for everything else. 546 + let saveBounds = lastPresentation?.bounds ?? lastBounds; 547 + const saveMaximized = lastPresentation?.maximized ?? false; 548 + const savePreMaxBounds: ClosedWindowEntry['preMaximizeBounds'] = 549 + lastPresentation?.preMaxBounds ?? undefined; 550 + 551 + // Fallback for canvas pages whose presentation didn't capture 552 + // (presenter returned null because the window was already 553 + // destroyed before our close listener fired): subtract the 554 + // fixed canvas chrome from window bounds. Slightly inflated 555 + // when panels were open in non-maximized mode, but only 556 + // reached on the unhappy path. 557 + if (saveBounds === lastBounds && lastBounds && isWebUrl 558 + && lastHostUrl && lastHostUrl.startsWith('peek://app/page/')) { 571 559 const CANVAS_MARGIN = 8; 572 560 const CANVAS_TRIGGER_ZONE = 8; 573 561 const CANVAS_NAVBAR_HEIGHT = 36; ··· 594 582 scrollX: scrollX || undefined, 595 583 scrollY: scrollY || undefined, 596 584 maximized: saveMaximized || undefined, 585 + preMaximizeBounds: savePreMaxBounds, 597 586 timestamp: Date.now(), 598 587 }); 599 588 } ··· 772 761 * Load all enabled features. Each feature is a tile. 773 762 * - Builtin features (cmd/hud/page core renderers) launch via core-glue. 774 763 * - Feature tiles in `features/` launch via the tile launcher (eager 775 - * if `resident: true` or in `EAGER_TILE_IDS`, otherwise lazy stubs). 764 + * if any tile entry has `resident: true` or in `EAGER_TILE_IDS`, otherwise lazy stubs). 776 765 */ 777 766 export async function loadFeatures(): Promise<number> { 778 767 const featStart = Date.now();
+60 -2
backend/electron/session.test.ts
··· 1628 1628 url: string, 1629 1629 params: Record<string, unknown>, 1630 1630 isVisible: boolean, 1631 - isDestroyed: boolean 1631 + isDestroyed: boolean, 1632 + hiddenByActiveOverlay: boolean = false 1632 1633 ): boolean { 1633 1634 if (isDestroyed) return true; 1634 - if (!isVisible) return true; 1635 + // Carve-out: a window hidden because an overlay is currently 1636 + // covering it is still real content — save it. 1637 + if (!isVisible && !hiddenByActiveOverlay) return true; 1635 1638 if (url.includes('peek://app/background.html')) return true; 1636 1639 if (params.modal) return true; 1637 1640 if (params.role === 'utility' && params.focusable === false) return true; 1641 + if (params.role === 'overlay' || params.overlay === true) return true; 1638 1642 return false; 1639 1643 } 1640 1644 ··· 1666 1670 false 1667 1671 ); 1668 1672 assert.strictEqual(skip, false, 'Focusable utility windows should be saved'); 1673 + }); 1674 + 1675 + it('SAVES windows hidden by an active overlay (regression: quitting with overlay open lost every other window)', () => { 1676 + // The IZUI coordinator hides other windows when an overlay opens. 1677 + // Those windows are real content the user wants restored — the 1678 + // !isVisible() skip must be carved out for them. 1679 + const skip = shouldSkipWindow( 1680 + 'https://example.com/', 1681 + { role: 'content' }, 1682 + false, // !isVisible — the overlay hid it 1683 + false, 1684 + true, // hiddenByActiveOverlay — coordinator says "this is overlay-hidden, not user-closed" 1685 + ); 1686 + assert.strictEqual(skip, false, 'Overlay-hidden content windows should still be saved'); 1687 + }); 1688 + 1689 + it('still skips genuinely-invisible windows when no overlay is hiding them', () => { 1690 + // Defensive: ensure the carve-out is gated. A window that's just 1691 + // !isVisible() with no overlay claiming it should still be skipped. 1692 + const skip = shouldSkipWindow( 1693 + 'https://example.com/', 1694 + { role: 'content' }, 1695 + false, 1696 + false, 1697 + false, // no overlay hiding it 1698 + ); 1699 + assert.strictEqual(skip, true, 'Genuinely-invisible windows still skipped'); 1700 + }); 1701 + 1702 + it('skips overlay-role windows like the Windows switcher', () => { 1703 + // Regression: an overlay was being persisted to the snapshot, so on 1704 + // launch the switcher would auto-open and the IZUI coordinator would 1705 + // hide everything else. Overlays are transient UI surfaces and 1706 + // should never auto-restore. 1707 + const skip = shouldSkipWindow( 1708 + 'peek://windows/windows.html', 1709 + { role: 'overlay', overlay: true, alwaysOnTop: true }, 1710 + true, 1711 + false 1712 + ); 1713 + assert.strictEqual(skip, true, 'Overlay-role windows should be skipped during save'); 1714 + }); 1715 + 1716 + it('skips windows flagged with overlay=true even without role=overlay', () => { 1717 + // The window-open option `overlay: true` is the authoritative signal 1718 + // that the IZUI coordinator should treat this as a hide-others surface. 1719 + // Skip on either signal so transient pickers don't survive restart. 1720 + const skip = shouldSkipWindow( 1721 + 'peek://something/picker.html', 1722 + { overlay: true }, 1723 + true, 1724 + false 1725 + ); 1726 + assert.strictEqual(skip, true, 'Windows flagged with overlay=true should be skipped during save'); 1669 1727 }); 1670 1728 1671 1729 it('skips modal windows', () => {
+64 -78
backend/electron/session.ts
··· 14 14 import { getAllWindows, getBackgroundWindow, pushClosedWindow, suppressClosedWindowPush } from './main.js'; 15 15 import type { ClosedWindowEntry } from './main.js'; 16 16 import { getRegisteredTileIds } from './protocol.js'; 17 + import { getWindowPresentation } from './window-presenter.js'; 17 18 18 19 import { publish, getSystemAddress } from './pubsub.js'; 19 20 import { DEBUG, isTestProfile } from './config.js'; ··· 50 51 createdAt: number; 51 52 reason: 'before-quit' | 'autosave' | 'manual'; 52 53 windows: WindowDescriptor[]; 53 - } 54 - 55 - /** 56 - * Extract the real URL from a page container URL. 57 - * Page containers use peek://app/page/index.html?url=<actual-url> 58 - * Returns the actual URL, or the original URL if not a container. 59 - */ 60 - function extractRealUrl(url: string): string { 61 - if (url.startsWith('peek://app/page')) { 62 - try { 63 - const parsed = new URL(url); 64 - const actualUrl = parsed.searchParams.get('url'); 65 - if (actualUrl) { 66 - return actualUrl; 67 - } 68 - } catch { 69 - // If URL parsing fails, keep the original 70 - } 71 - } 72 - return url; 73 - } 74 - 75 - /** 76 - * Extract webview bounds from a canvas page container URL. 77 - * Canvas pages encode the visible webview position/size in URL params. 78 - * Returns null if the URL is not a canvas page or params are missing. 79 - */ 80 - function extractCanvasBounds(url: string): Electron.Rectangle | null { 81 - if (!url.startsWith('peek://app/page')) return null; 82 - try { 83 - const parsed = new URL(url); 84 - const x = parsed.searchParams.get('x'); 85 - const y = parsed.searchParams.get('y'); 86 - const width = parsed.searchParams.get('width'); 87 - const height = parsed.searchParams.get('height'); 88 - if (x && y && width && height) { 89 - return { 90 - x: parseInt(x, 10), 91 - y: parseInt(y, 10), 92 - width: parseInt(width, 10), 93 - height: parseInt(height, 10), 94 - }; 95 - } 96 - } catch { 97 - // If URL parsing fails, skip 98 - } 99 - return null; 100 54 } 101 55 102 56 /** ··· 210 164 zOrderMap.set(win.id, index); 211 165 }); 212 166 167 + // Collect all "hidden by overlay" window IDs across the registry. When 168 + // an overlay (Windows switcher, etc.) is open at quit time, the IZUI 169 + // coordinator has hidden every other window via win.hide() — so they 170 + // report !isVisible() in the loop below. Without this carve-out the 171 + // !isVisible() skip drops them from the snapshot, and quitting with 172 + // an overlay open silently loses every other window. The overlay 173 + // tracks the IDs in info.params.overlayHiddenWindows; gather them all 174 + // here so the loop's visibility check can let them through. 175 + const overlayHiddenIds = new Set<number>(); 176 + for (const [, winData] of registeredWindows) { 177 + const ids = winData.params.overlayHiddenWindows; 178 + if (Array.isArray(ids)) { 179 + for (const hid of ids) { 180 + if (typeof hid === 'number') overlayHiddenIds.add(hid); 181 + } 182 + } 183 + } 184 + 213 185 for (const [id, winData] of registeredWindows) { 214 186 const win = BrowserWindow.fromId(id); 215 187 ··· 218 190 continue; 219 191 } 220 192 221 - // Skip invisible windows (test override skips this check so headless 222 - // page-hosts — which Electron reports as not visible — can still be 223 - // saved + restored by the session-restore Playwright spec). 224 - if (!win.isVisible() && !opts?._forceForTest) { 193 + // Skip invisible windows. Carve-out: windows hidden by an active 194 + // overlay (tracked above in overlayHiddenIds) should still be saved 195 + // so the user gets them back on next launch — they're real content 196 + // that just happens to be temporarily covered by a transient picker. 197 + if (!win.isVisible() && !opts?._forceForTest && !overlayHiddenIds.has(id)) { 225 198 continue; 226 199 } 227 200 ··· 244 217 continue; 245 218 } 246 219 247 - // Extract real URL from page containers 248 - const realUrl = extractRealUrl(url); 220 + // Skip overlay-role / overlay-flagged windows. Overlays (Windows 221 + // switcher, find-bar, peek-card, etc.) are transient UI surfaces 222 + // that should not auto-restore on launch. Restoring an overlay 223 + // also activates the IZUI coordinator's hide-others behavior, so 224 + // users see startup with everything marked hidden and the overlay 225 + // sitting on top — never the intent. 226 + if (winData.params.role === 'overlay' || winData.params.overlay === true) { 227 + DEBUG && console.log(`[session:save] Skipping overlay window ${id}`); 228 + continue; 229 + } 230 + 231 + // Use the window presenter for the canonical user-facing snapshot. 232 + // It encapsulates page-host's two-layer URL shape, canvas bounds, and 233 + // maximize state — session.ts no longer special-cases any window type. 234 + const presentation = getWindowPresentation(id); 235 + if (!presentation) { 236 + DEBUG && console.log(`[session:save] No presentation for window ${id}, skipping`); 237 + continue; 238 + } 239 + const realUrl = presentation.address; 240 + const windowBounds = presentation.bounds; 241 + const canvasMaximized = presentation.maximized; 249 242 250 243 // Skip windows for unknown tiles (stale from renames/removals) 251 244 const extMatch = realUrl.match(/^peek:\/\/ext\/([^/]+)/); ··· 258 251 } 259 252 } 260 253 261 - // For canvas pages (web URLs in peek://app/page container), use the webview 262 - // bounds from URL params, not the fullscreen transparent BrowserWindow bounds. 263 - const canvasBounds = extractCanvasBounds(url); 264 - const windowBounds = canvasBounds || win.getBounds(); 265 - 266 - // Detect the page-host's maximized state from its URL param so restore 267 - // can re-enter MAXIMIZED on the page-host FSM rather than booting NORMAL 268 - // and leaving body class / handle visibility / URL param out of sync 269 - // (see docs/page-host-state-machine.md). 270 - let canvasMaximized = false; 271 - if (url.startsWith('peek://app/page')) { 272 - try { 273 - canvasMaximized = new URL(url).searchParams.get('maximized') === '1'; 274 - } catch { /* unparseable; treat as not maximized */ } 275 - } 276 - 277 - console.log(`[session:save] Window ${id} "${win.getTitle()}" bounds: (${windowBounds.x},${windowBounds.y}) ${windowBounds.width}x${windowBounds.height}${canvasBounds ? " [canvas]" : ""}${canvasMaximized ? " [maximized]" : ""} url=${realUrl.substring(0, 80)}`); 254 + console.log(`[session:save] Window ${id} "${presentation.title}" bounds: (${windowBounds.x},${windowBounds.y}) ${windowBounds.width}x${windowBounds.height}${canvasMaximized ? " [maximized]" : ""} url=${realUrl.substring(0, 80)}`); 278 255 279 256 // Capture context state for this window 280 257 let windowContext: WindowContext | undefined; ··· 298 275 windowDescriptors.push({ 299 276 url: realUrl, 300 277 key: (winData.params.key as string) || undefined, 301 - title: win.getTitle(), 278 + title: presentation.title, 302 279 bounds: windowBounds, 303 280 source: winData.source, 304 281 params: sanitized, 305 282 zOrder: zOrderMap.get(id) ?? 0, 306 - focused: win.isFocused(), 283 + focused: presentation.focused, 307 284 context: windowContext, 308 285 }); 309 286 } ··· 681 658 DEBUG && console.log(`[session] Skipping invalid/stale window: ${descriptor.url || '(empty)'}`); 682 659 return false; 683 660 } 661 + // Defense in depth: drop any overlay-role descriptors that snuck into 662 + // older snapshots before the save-side filter was added. Restoring an 663 + // overlay activates the IZUI hide-others behavior and produces a 664 + // startup with everything hidden behind a transient picker. 665 + const role = descriptor.params?.role; 666 + const overlayFlag = (descriptor.params as Record<string, unknown> | undefined)?.overlay; 667 + if (role === 'overlay' || overlayFlag === true) { 668 + DEBUG && console.log(`[session] Skipping stale overlay window in snapshot: ${descriptor.url || '(empty)'}`); 669 + return false; 670 + } 684 671 return true; 685 672 }); 686 673 ··· 959 946 const spaceName = metadata?.spaceName as string | undefined; 960 947 if (!spaceId) continue; 961 948 962 - const realUrl = extractRealUrl(rawUrl); 963 - const canvasBounds = extractCanvasBounds(rawUrl); 964 - const bounds = canvasBounds || win.getBounds(); 949 + const presentation = getWindowPresentation(id); 950 + if (!presentation) continue; 965 951 966 952 if (!spaceWindows.has(spaceId)) { 967 953 spaceWindows.set(spaceId, { spaceName: spaceName || '', windows: [] }); 968 954 } 969 955 spaceWindows.get(spaceId)!.windows.push({ 970 - url: realUrl, 971 - bounds, 956 + url: presentation.address, 957 + bounds: presentation.bounds, 972 958 zOrder: zOrderMap.get(id) ?? 0, 973 - focused: win.isFocused(), 959 + focused: presentation.focused, 974 960 }); 975 961 } 976 962
+13
backend/electron/tile-api.d.ts
··· 160 160 maximize(id?: number): Promise<{ success: boolean; error?: string }>; 161 161 162 162 /** 163 + * Push the current canvas page-host state into the registry params so 164 + * session save and the close handler can read from a single source of 165 + * truth. Called by the page-host renderer on every state-changing 166 + * event (resize end, drag end, doMaximize, doUnmaximize). Requires 167 + * `window.manage` capability. 168 + */ 169 + updateCanvasState(state: { 170 + x: number; y: number; width: number; height: number; 171 + maximized: boolean; 172 + preMaxX?: number; preMaxY?: number; preMaxWidth?: number; preMaxHeight?: number; 173 + }): Promise<{ success: boolean; error?: string }>; 174 + 175 + /** 163 176 * Toggle fullscreen on a window. Omit id for calling window. 164 177 * Requires `window.manage` capability. 165 178 */
+119 -17
backend/electron/tile-ipc.ts
··· 93 93 getWindowInfo, 94 94 validateThemeCSS, 95 95 } from './main.js'; 96 + import { getWindowPresentation } from './window-presenter.js'; 96 97 import { 97 98 registerGlobalShortcut, 98 99 unregisterGlobalShortcut, ··· 2620 2621 } 2621 2622 2622 2623 try { 2624 + // Use the window presenter so the response carries user-facing facts 2625 + // (resolved address for page-hosts, canvas-aware bounds, favicon, etc.) 2626 + // — consumers don't have to know about page-host shell URLs. 2623 2627 const windows: Array<{ 2624 2628 id: number; 2625 2629 url: string; 2626 2630 title: string; 2627 2631 focused: boolean; 2628 2632 visible: boolean; 2633 + favicon: string; 2634 + thumbnail: string; 2635 + bounds: Electron.Rectangle; 2636 + maximized: boolean; 2637 + preMaxBounds: Electron.Rectangle | null; 2629 2638 params: Record<string, unknown>; 2630 2639 }> = []; 2631 2640 for (const win of BrowserWindow.getAllWindows()) { ··· 2634 2643 // When includeInternal is false/absent, skip windows without registry 2635 2644 // entries (e.g. purely internal Electron windows). When true, include all. 2636 2645 if (!args.includeInternal && !info) continue; 2646 + const presentation = getWindowPresentation(win.id); 2647 + if (!presentation) continue; 2637 2648 windows.push({ 2638 - id: win.id, 2639 - url: win.webContents.getURL(), 2640 - title: win.getTitle(), 2641 - focused: win.isFocused(), 2642 - visible: win.isVisible(), 2649 + id: presentation.id, 2650 + // Field is named `url` for backward compat; value is the resolved 2651 + // user-facing address (inner URL for page-hosts). 2652 + url: presentation.address, 2653 + title: presentation.title, 2654 + focused: presentation.focused, 2655 + visible: presentation.visible, 2656 + favicon: presentation.favicon, 2657 + thumbnail: presentation.thumbnail, 2658 + bounds: presentation.bounds, 2659 + maximized: presentation.maximized, 2660 + preMaxBounds: presentation.preMaxBounds, 2643 2661 params: info ? info.params : {}, 2644 2662 }); 2645 2663 } ··· 3033 3051 // `maximize` again, and have the new smaller size become the new 3034 3052 // pre-maximize snapshot — instead of being stuck with the original 3035 3053 // snapshot from the first maximize. 3054 + // Stored as flat primitives so they survive sanitizeParams in 3055 + // session save (object values are stripped). 3036 3056 if (!atWorkArea && info) { 3037 - info.params.preMaximizeBounds = { 3038 - x: currentBounds.x, 3039 - y: currentBounds.y, 3040 - width: currentBounds.width, 3041 - height: currentBounds.height, 3042 - }; 3057 + info.params.preMaxX = currentBounds.x; 3058 + info.params.preMaxY = currentBounds.y; 3059 + info.params.preMaxWidth = currentBounds.width; 3060 + info.params.preMaxHeight = currentBounds.height; 3061 + } 3062 + // info.params.maximized is the source of truth for session save — 3063 + // keep it in sync here for the cmd-driven path. The renderer-driven 3064 + // path (dblclick) updates it via tile:window:update-canvas-state. 3065 + if (info) { 3066 + info.params.maximized = true; 3043 3067 } 3044 3068 3045 3069 win.setBounds(workArea); ··· 3096 3120 return { success: false, error: 'Window not found' }; 3097 3121 } 3098 3122 const info = getWindowInfo(win.id); 3123 + const px = info?.params?.preMaxX, py = info?.params?.preMaxY; 3124 + const pw = info?.params?.preMaxWidth, ph = info?.params?.preMaxHeight; 3125 + const stashed = (typeof px === 'number' && typeof py === 'number' 3126 + && typeof pw === 'number' && typeof ph === 'number') 3127 + ? { x: px, y: py, width: pw, height: ph } 3128 + : undefined; 3099 3129 try { 3100 3130 console.log( 3101 - `[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)}` 3131 + `[tile:window:unmaximize] id=${args.id ?? 'sender-fallback'} resolved=${win.id} hasPreBounds=${stashed != null} isMaximized=${win.isMaximized()} url=${win.webContents.getURL().slice(0, 80)}` 3102 3132 ); 3103 3133 } catch { /* logging must never throw */ } 3104 3134 3105 - const stashed = info?.params?.preMaximizeBounds as 3106 - | { x: number; y: number; width: number; height: number } 3107 - | undefined; 3108 - 3109 3135 if (stashed) { 3110 3136 // Clear electron's own maximize flag too, in case it's set. 3111 3137 if (win.isMaximized()) { 3112 3138 win.unmaximize(); 3113 3139 } 3114 3140 win.setBounds(stashed); 3115 - delete info!.params.preMaximizeBounds; 3116 3141 } else if (win.isMaximized()) { 3117 3142 win.unmaximize(); 3143 + } 3144 + // Clear cmd-driven path's pre-max snapshot + maximize flag in 3145 + // info.params; the renderer-driven path (dblclick) clears them via 3146 + // tile:window:update-canvas-state. 3147 + if (info) { 3148 + delete info.params.preMaxX; 3149 + delete info.params.preMaxY; 3150 + delete info.params.preMaxWidth; 3151 + delete info.params.preMaxHeight; 3152 + info.params.maximized = false; 3118 3153 } 3119 3154 // Targeted notification — see comment in tile:window:maximize. 3120 3155 try { win.webContents.send('tile:window:unmaximize-request'); } catch { /* renderer may be gone */ } 3156 + return { success: true }; 3157 + } catch (err) { 3158 + const message = err instanceof Error ? err.message : String(err); 3159 + return { success: false, error: message }; 3160 + } 3161 + }); 3162 + 3163 + // ── tile:window:update-canvas-state ───────────────────────────────── 3164 + // 3165 + // Renderer-driven canvas page-host state sync. The page-host owns the 3166 + // visible webview bounds and the maximize transitions inside the 3167 + // fullscreen transparent window; whenever they change (resize END, 3168 + // drag END, doMaximize, doUnmaximize, drag-out-of-maximize), the 3169 + // renderer pushes the new state into `info.params` via this IPC. 3170 + // Session save and the close handler read from `info.params` (rather 3171 + // than parsing URL search params), so this is the single source of 3172 + // truth for canvas state at quit time. 3173 + // 3174 + // Gated by the same `window.manage` capability as maximize/unmaximize 3175 + // — only the window's own renderer (or trusted callers with manage 3176 + // grant) can update the registry entry. 3177 + registerTileIpc('tile:window:update-canvas-state', { mode: 'handle' }, async (event, args: { 3178 + token: string; 3179 + x: number; 3180 + y: number; 3181 + width: number; 3182 + height: number; 3183 + maximized: boolean; 3184 + preMaxX?: number; 3185 + preMaxY?: number; 3186 + preMaxWidth?: number; 3187 + preMaxHeight?: number; 3188 + }, _grant) => { 3189 + const grant = _grant; 3190 + const check = checkWindowAllowed(grant, 'maximize'); 3191 + if (!check.ok) { 3192 + handleViolation(grant, 'window', 'window:update-canvas-state', check.error, args.token); 3193 + return { success: false, error: check.error }; 3194 + } 3195 + 3196 + try { 3197 + const win = BrowserWindow.fromWebContents(event.sender); 3198 + if (!win || win.isDestroyed()) { 3199 + return { success: false, error: 'Window not found' }; 3200 + } 3201 + const info = getWindowInfo(win.id); 3202 + if (!info) { 3203 + // No registry entry — nothing to update. Treat as success so the 3204 + // renderer doesn't retry. 3205 + return { success: true }; 3206 + } 3207 + if (typeof args.x === 'number') info.params.x = args.x; 3208 + if (typeof args.y === 'number') info.params.y = args.y; 3209 + if (typeof args.width === 'number') info.params.width = args.width; 3210 + if (typeof args.height === 'number') info.params.height = args.height; 3211 + info.params.maximized = !!args.maximized; 3212 + if (args.maximized) { 3213 + if (typeof args.preMaxX === 'number') info.params.preMaxX = args.preMaxX; 3214 + if (typeof args.preMaxY === 'number') info.params.preMaxY = args.preMaxY; 3215 + if (typeof args.preMaxWidth === 'number') info.params.preMaxWidth = args.preMaxWidth; 3216 + if (typeof args.preMaxHeight === 'number') info.params.preMaxHeight = args.preMaxHeight; 3217 + } else { 3218 + delete info.params.preMaxX; 3219 + delete info.params.preMaxY; 3220 + delete info.params.preMaxWidth; 3221 + delete info.params.preMaxHeight; 3222 + } 3121 3223 return { success: true }; 3122 3224 } catch (err) { 3123 3225 const message = err instanceof Error ? err.message : String(err);
+19
backend/electron/tile-preload.cts
··· 733 733 }, 734 734 735 735 /** 736 + * Push the current canvas page-host state into the window's registry 737 + * params. Used by the page-host renderer to keep info.params in sync 738 + * with the live state (visible webview bounds + maximize state + 739 + * pre-maximize bounds when maximized) so session save and the 740 + * close-undo handler can read from a single source of truth at quit 741 + * time. Requires `window.manage` (or trustedBuiltin). 742 + */ 743 + updateCanvasState: (state: { 744 + x: number; y: number; width: number; height: number; 745 + maximized: boolean; 746 + preMaxX?: number; preMaxY?: number; preMaxWidth?: number; preMaxHeight?: number; 747 + }) => { 748 + return ipcRenderer.invoke('tile:window:update-canvas-state', { 749 + token: tileToken, 750 + ...state, 751 + }); 752 + }, 753 + 754 + /** 736 755 * Toggle fullscreen on a window. Requires `window.manage` (or 737 756 * trustedBuiltin). 738 757 */
backend/electron/window-address.test.ts

This is a binary file and will not be displayed.

+42
backend/electron/window-address.ts
··· 1 + /** 2 + * Pure helper for resolving a window's user-facing address. 3 + * 4 + * Lives separately from window-presenter.ts because the presenter imports 5 + * Electron's BrowserWindow at top level, which fails under 6 + * ELECTRON_RUN_AS_NODE — so unit tests need a pure-JS module to import. 7 + * 8 + * The resolution rules encoded here are the contract every consumer of 9 + * `tile:window:list`, `session.save`, and undo-close depends on. The 10 + * page-host's two-layer URL shape lives here and nowhere else. 11 + */ 12 + 13 + /** 14 + * For page-host windows the BrowserWindow's URL is `peek://app/page/...` 15 + * (the shell). The user-facing URL is the inner navigated address. 16 + * 17 + * Priority: 18 + * 1. info.params.currentUrl (kept in sync by ipc.ts's 19 + * did-attach-webview listener — tracks every guest did-navigate). 20 + * 2. The bootstrap `?url=` search param on the host URL (what was 21 + * passed at window-open). 22 + * 3. The host URL itself (last-resort fallback). 23 + * 24 + * For all other windows, returns the host URL unchanged. 25 + */ 26 + export function resolveWindowAddress( 27 + hostUrl: string, 28 + params: Record<string, unknown> | undefined, 29 + ): string { 30 + if (!hostUrl.startsWith('peek://app/page')) return hostUrl; 31 + const liveUrl = typeof params?.currentUrl === 'string' 32 + ? (params.currentUrl as string) 33 + : ''; 34 + if (liveUrl) return liveUrl; 35 + try { 36 + const bootstrap = new URL(hostUrl).searchParams.get('url'); 37 + if (bootstrap) return bootstrap; 38 + } catch { 39 + // hostUrl unparseable — fall through to hostUrl itself 40 + } 41 + return hostUrl; 42 + }
+138
backend/electron/window-presenter.ts
··· 1 + /** 2 + * Window presenter — canonical user-facing self-description for any window. 3 + * 4 + * Consumers (tile:window:list, session.save, undo-close handler, future 5 + * features) ask for a `WindowPresentation` and never reach into per-type 6 + * internals — no parsing the page-host's shell URL, no peeking at 7 + * info.params.currentUrl or .x/.y, no extractRealUrl. That window-type 8 + * knowledge lives here, in one place. 9 + * 10 + * Adding a new window type means extending this module — no edits to 11 + * any consumer. 12 + * 13 + * See peek MCP task `e4ae700e` for design rationale and the catalogue of 14 + * bugs that motivated this consolidation. 15 + */ 16 + 17 + import { BrowserWindow } from 'electron'; 18 + import { getWindowInfo } from './main.js'; 19 + import { findUrlItem } from './datastore.js'; 20 + import { resolveWindowAddress } from './window-address.js'; 21 + 22 + export { resolveWindowAddress }; 23 + 24 + export interface WindowPresentation { 25 + id: number; 26 + /** 27 + * The user-facing URL. For page-host windows this resolves to the inner 28 + * navigated URL (info.params.currentUrl, falling back to the bootstrap 29 + * `url` search param), NOT the `peek://app/page/index.html?…` shell. 30 + * For all other windows this is webContents.getURL(). 31 + */ 32 + address: string; 33 + title: string; 34 + /** Favicon URL stored on the matching items-table row, or empty string. */ 35 + favicon: string; 36 + /** Thumbnail hash for `peek://thumbnail/<hash>.jpg`, or empty string. */ 37 + thumbnail: string; 38 + /** 39 + * For page-hosts, the visible webview rect (canvas bounds from 40 + * info.params). For other windows, BrowserWindow.getBounds(). Always 41 + * the rect a consumer would want to display or persist. 42 + */ 43 + bounds: Electron.Rectangle; 44 + /** True when the page-host is in MAXIMIZED state (canvas-internal). */ 45 + maximized: boolean; 46 + /** Pre-maximize bounds for unmaximize, or null if not maximized. */ 47 + preMaxBounds: Electron.Rectangle | null; 48 + visible: boolean; 49 + focused: boolean; 50 + } 51 + 52 + /** 53 + * Return the canonical presentation for a window, or null if the window 54 + * doesn't exist or has been destroyed. 55 + */ 56 + export function getWindowPresentation(windowId: number): WindowPresentation | null { 57 + const win = BrowserWindow.fromId(windowId); 58 + if (!win || win.isDestroyed()) return null; 59 + 60 + const info = getWindowInfo(windowId); 61 + const hostUrl = win.webContents.getURL(); 62 + const isCanvasPage = hostUrl.startsWith('peek://app/page'); 63 + 64 + const address = resolveWindowAddress(hostUrl, info?.params); 65 + 66 + // Bounds: canvas pages publish their visible-webview rect into 67 + // info.params via tile:window:update-canvas-state; everyone else uses 68 + // the BrowserWindow rect. 69 + let bounds = win.getBounds(); 70 + if (isCanvasPage && info) { 71 + const px = info.params.x; 72 + const py = info.params.y; 73 + const pw = info.params.width; 74 + const ph = info.params.height; 75 + if (typeof px === 'number' && typeof py === 'number' 76 + && typeof pw === 'number' && typeof ph === 'number') { 77 + bounds = { x: px, y: py, width: pw, height: ph }; 78 + } 79 + } 80 + 81 + // Maximize state — only meaningful for canvas pages. 82 + const maximized = isCanvasPage && info?.params?.maximized === true; 83 + let preMaxBounds: Electron.Rectangle | null = null; 84 + if (maximized && info) { 85 + const ppx = info.params.preMaxX; 86 + const ppy = info.params.preMaxY; 87 + const ppw = info.params.preMaxWidth; 88 + const pph = info.params.preMaxHeight; 89 + if (typeof ppx === 'number' && typeof ppy === 'number' 90 + && typeof ppw === 'number' && typeof pph === 'number') { 91 + preMaxBounds = { x: ppx, y: ppy, width: ppw, height: pph }; 92 + } 93 + } 94 + 95 + // Favicon + thumbnail. The presenter is the one place that knows the 96 + // resolution policy — renderers receive a URL string they can drop into 97 + // an <img src>, with no fallback chain of their own. 98 + // 99 + // Favicon priority: stored item.favicon → Google s2 service. 100 + // (When peek://favicon/<hash> local-cache lands per task 7927ab71, it 101 + // becomes a third layer here without touching any consumer.) 102 + // 103 + // Thumbnail: stored hash only — no third-party screenshot service. 104 + let favicon = ''; 105 + let thumbnail = ''; 106 + if (address.startsWith('http://') || address.startsWith('https://')) { 107 + try { 108 + const item = findUrlItem(address) as { favicon?: string; thumbnail?: string } | null; 109 + if (item) { 110 + favicon = item.favicon || ''; 111 + thumbnail = item.thumbnail || ''; 112 + } 113 + } catch { 114 + // best-effort — store lookup failures shouldn't break window listings 115 + } 116 + if (!favicon) { 117 + try { 118 + const host = new URL(address).hostname; 119 + if (host) favicon = `https://www.google.com/s2/favicons?domain=${host}&sz=32`; 120 + } catch { 121 + // unparseable address — leave favicon empty 122 + } 123 + } 124 + } 125 + 126 + return { 127 + id: windowId, 128 + address, 129 + title: win.getTitle(), 130 + favicon, 131 + thumbnail, 132 + bounds, 133 + maximized, 134 + preMaxBounds, 135 + visible: win.isVisible(), 136 + focused: win.isFocused(), 137 + }; 138 + }
+20 -6
features/windows/windows.css
··· 77 77 78 78 /* Window card slotted content */ 79 79 .card-favicon { 80 - width: 12px; 81 - height: 12px; 82 - border-radius: 4px; 80 + width: 16px; 81 + height: 16px; 82 + border-radius: 3px; 83 83 flex-shrink: 0; 84 84 background: var(--base02); 85 85 object-fit: contain; 86 - margin-top: 4px; 86 + } 87 + 88 + .card-thumbnail { 89 + display: block; 90 + width: 100%; 91 + aspect-ratio: 3 / 2; 92 + object-fit: cover; 93 + object-position: top center; 94 + background: var(--base02); 95 + border-radius: 4px; 96 + border: 1px solid var(--base02); 97 + } 98 + 99 + peek-card[data-hidden="true"] .card-thumbnail { 100 + filter: grayscale(0.4); 87 101 } 88 102 89 103 .card-title { 90 - font-size: 15px; 104 + font-size: 14px; 91 105 font-weight: 600; 92 106 color: var(--base05); 93 107 white-space: nowrap; ··· 96 110 } 97 111 98 112 .card-url { 99 - font-size: 12px; 113 + font-size: 11px; 100 114 color: var(--base04); 101 115 white-space: nowrap; 102 116 overflow: hidden;
+20 -12
features/windows/windows.js
··· 268 268 header.style.gap = '8px'; 269 269 header.style.minWidth = '0'; 270 270 271 - // Try to get favicon from URL (only for external URLs) 272 - if (win.url && !win.url.startsWith('peek://')) { 273 - try { 274 - const url = new URL(win.url); 275 - const favicon = document.createElement('img'); 276 - favicon.className = 'card-favicon'; 277 - favicon.src = `${url.origin}/favicon.ico`; 278 - favicon.onerror = () => favicon.remove(); 279 - header.appendChild(favicon); 280 - } catch (e) { 281 - // No favicon 282 - } 271 + // Favicon comes from the window presentation (api.window.list resolves 272 + // the right source — stored value, then Google s2). Renderer just drops 273 + // the URL into <img src>; no fallback chain here. 274 + if (win.favicon) { 275 + const favicon = document.createElement('img'); 276 + favicon.className = 'card-favicon'; 277 + favicon.src = win.favicon; 278 + favicon.onerror = () => favicon.remove(); 279 + header.appendChild(favicon); 283 280 } 284 281 285 282 const title = document.createElement('span'); ··· 295 292 } 296 293 297 294 card.appendChild(header); 295 + 296 + // Page-screenshot thumbnail when stored. peek://thumbnail/<hash>.jpg is 297 + // served by the protocol handler from userData/thumbnails/. 298 + if (win.thumbnail) { 299 + const thumb = document.createElement('img'); 300 + thumb.className = 'card-thumbnail'; 301 + thumb.src = `peek://thumbnail/${win.thumbnail}.jpg`; 302 + thumb.onerror = () => thumb.remove(); 303 + thumb.alt = ''; 304 + card.appendChild(thumb); 305 + } 298 306 299 307 // URL in body 300 308 const url = document.createElement('div');
+191
tests/desktop/session-restore-page-host.spec.ts
··· 257 257 expect(afterState.seHandleHidden, 258 258 `restored page host shows resize handles even though body class says maximized — state drift`).toBe(true); 259 259 }); 260 + 261 + test('maximize → unmaximize → resize smaller → restart: bounds restore at resized size, not maximized', async () => { 262 + const url = `http://127.0.0.1:${serverPort}/unmax-resize-roundtrip`; 263 + const { pageWindow, windowId } = await openPageHost(url); 264 + 265 + await pageWindow.waitForFunction(() => { 266 + const p = new URL(window.location.href).searchParams; 267 + return p.get('width') && p.get('height'); 268 + }); 269 + 270 + // Capture pre-maximize URL bounds for comparison. 271 + const preMaximizeUrl = await getHostState(pageWindow); 272 + const preMaximizeWidth = parseInt(preMaximizeUrl.urlParams.width as string, 10); 273 + const preMaximizeHeight = parseInt(preMaximizeUrl.urlParams.height as string, 10); 274 + 275 + // Maximize. 276 + await maximizePageHost(pageWindow); 277 + await waitForBodyMaximized(pageWindow, true); 278 + 279 + // Unmaximize via the same navbar dblclick path. 280 + await maximizePageHost(pageWindow); 281 + await waitForBodyMaximized(pageWindow, false); 282 + 283 + // Resize smaller through the SE corner handle — pointer events drive 284 + // the same FSM path the user does. Drag the SE handle 200px up-left 285 + // to shrink the window. 286 + await pageWindow.evaluate(() => { 287 + const handle = document.getElementById('resize-se')!; 288 + handle.dispatchEvent(new PointerEvent('pointerdown', { 289 + bubbles: true, button: 0, pointerId: 1, screenX: 600, screenY: 500, 290 + })); 291 + handle.dispatchEvent(new PointerEvent('pointermove', { 292 + bubbles: true, button: 0, buttons: 1, pointerId: 1, screenX: 400, screenY: 300, 293 + })); 294 + handle.dispatchEvent(new PointerEvent('pointerup', { 295 + bubbles: true, button: 0, pointerId: 1, 296 + })); 297 + }); 298 + 299 + // Wait for the resize to settle in URL params. 300 + await pageWindow.waitForFunction(({ w, h }) => { 301 + const p = new URL(window.location.href).searchParams; 302 + const cw = parseInt(p.get('width') || '0', 10); 303 + const ch = parseInt(p.get('height') || '0', 10); 304 + return cw < w && ch < h && !p.has('maximized'); 305 + }, { w: preMaximizeWidth, h: preMaximizeHeight }); 306 + 307 + const beforeState = await getHostState(pageWindow); 308 + expect(beforeState.bodyMaximized, 309 + `pre-save: body.maximized must be false after unmaximize+resize`).toBe(false); 310 + expect(beforeState.urlParams.maximized, 311 + `pre-save: URL must NOT carry maximized=1 after unmaximize+resize`).toBeUndefined(); 312 + const resizedWidth = parseInt(beforeState.urlParams.width as string, 10); 313 + const resizedHeight = parseInt(beforeState.urlParams.height as string, 10); 314 + 315 + // Save. 316 + await app.evaluateMain!((() => { 317 + return (globalThis as any).__peek_test.forceSaveSession(); 318 + }) as any); 319 + 320 + // Close. 321 + await bgWindow.evaluate((id: number) => { 322 + (window as any).__unmaxRemoved = new Promise<void>((resolve) => { 323 + const handler = (msg: { id?: number }) => { 324 + if (msg?.id === id) { 325 + try { (window as any).app.pubsub.unsubscribe('window:removed', handler); } catch {} 326 + resolve(); 327 + } 328 + }; 329 + (window as any).app.pubsub.subscribe('window:removed', handler); 330 + }); 331 + (window as any).app.window.close(id); 332 + }, windowId); 333 + await bgWindow.evaluate(() => (window as any).__unmaxRemoved); 334 + 335 + // Restore. 336 + const restoreResult = await app.evaluateMain!((() => { 337 + return (globalThis as any).__peek_test.forceRestoreSession(); 338 + }) as any) as { restored: number; failed: number; total: number }; 339 + expect(restoreResult.restored).toBeGreaterThan(0); 340 + 341 + const restoredPageWindow = await app.getWindow('unmax-resize-roundtrip', 15000); 342 + await restoredPageWindow.waitForFunction(() => { 343 + const p = new URL(window.location.href).searchParams; 344 + return p.get('width') && p.get('height'); 345 + }); 346 + 347 + const afterState = await getHostState(restoredPageWindow); 348 + 349 + expect(afterState.bodyMaximized, 350 + `restored page host should NOT be maximized — user unmaximized + resized before restart`).toBe(false); 351 + expect(afterState.urlParams.maximized, 352 + `restored URL should NOT carry maximized=1`).toBeUndefined(); 353 + expect(parseInt(afterState.urlParams.width as string, 10), 354 + `restored width should match resized width`).toBe(resizedWidth); 355 + expect(parseInt(afterState.urlParams.height as string, 10), 356 + `restored height should match resized height`).toBe(resizedHeight); 357 + }); 358 + 359 + test('maximize → save+restore → unmaximize: restores to pre-maximize bounds (not workArea minus chrome)', async () => { 360 + const url = `http://127.0.0.1:${serverPort}/premax-roundtrip`; 361 + const { pageWindow, windowId } = await openPageHost(url); 362 + 363 + await pageWindow.waitForFunction(() => { 364 + const p = new URL(window.location.href).searchParams; 365 + return p.get('width') && p.get('height'); 366 + }); 367 + 368 + // Capture pre-maximize bounds — the size the window has BEFORE we 369 + // maximize. After the save/restore cycle and a subsequent unmaximize, 370 + // the window must come back to this size. 371 + const preState = await getHostState(pageWindow); 372 + const preMaxWidth = parseInt(preState.urlParams.width as string, 10); 373 + const preMaxHeight = parseInt(preState.urlParams.height as string, 10); 374 + expect(preState.bodyMaximized).toBe(false); 375 + 376 + // Maximize via dblclick — captures preMaximizeBounds in renderer state 377 + // and writes preX/preY/preWidth/preHeight to the URL. 378 + await maximizePageHost(pageWindow); 379 + await waitForBodyMaximized(pageWindow, true); 380 + 381 + // Verify the URL carries the pre-maximize bounds. 382 + const maxState = await getHostState(pageWindow); 383 + expect(maxState.urlParams.maximized).toBe('1'); 384 + expect(parseInt(maxState.urlParams.preWidth as string, 10), 385 + `URL should carry preWidth so save+restore can preserve pre-maximize size`) 386 + .toBe(preMaxWidth); 387 + expect(parseInt(maxState.urlParams.preHeight as string, 10)).toBe(preMaxHeight); 388 + 389 + // Save. 390 + await app.evaluateMain!((() => { 391 + return (globalThis as any).__peek_test.forceSaveSession(); 392 + }) as any); 393 + 394 + // Close. 395 + await bgWindow.evaluate((id: number) => { 396 + (window as any).__premaxRemoved = new Promise<void>((resolve) => { 397 + const handler = (msg: { id?: number }) => { 398 + if (msg?.id === id) { 399 + try { (window as any).app.pubsub.unsubscribe('window:removed', handler); } catch {} 400 + resolve(); 401 + } 402 + }; 403 + (window as any).app.pubsub.subscribe('window:removed', handler); 404 + }); 405 + (window as any).app.window.close(id); 406 + }, windowId); 407 + await bgWindow.evaluate(() => (window as any).__premaxRemoved); 408 + 409 + // Restore. 410 + const restoreResult = await app.evaluateMain!((() => { 411 + return (globalThis as any).__peek_test.forceRestoreSession(); 412 + }) as any) as { restored: number; failed: number; total: number }; 413 + expect(restoreResult.restored).toBeGreaterThan(0); 414 + 415 + const restoredPageWindow = await app.getWindow('premax-roundtrip', 15000); 416 + await restoredPageWindow.waitForFunction(() => { 417 + const p = new URL(window.location.href).searchParams; 418 + return p.get('maximized') === '1'; 419 + }); 420 + 421 + const restoredMaxState = await getHostState(restoredPageWindow); 422 + expect(restoredMaxState.bodyMaximized, 423 + `after restore: body should be maximized`).toBe(true); 424 + expect(restoredMaxState.urlParams.preWidth, 425 + `after restore: URL should still carry preWidth so unmaximize knows where to go`) 426 + .toBe(String(preMaxWidth)); 427 + expect(restoredMaxState.urlParams.preHeight) 428 + .toBe(String(preMaxHeight)); 429 + 430 + // Now unmaximize on the restored window — must land at the original 431 + // pre-maximize size, not at workArea-minus-chrome. 432 + await maximizePageHost(restoredPageWindow); 433 + await waitForBodyMaximized(restoredPageWindow, false); 434 + 435 + // Wait for URL to reflect the unmaximize bounds. 436 + await restoredPageWindow.waitForFunction(({ w, h }) => { 437 + const p = new URL(window.location.href).searchParams; 438 + return parseInt(p.get('width') || '0', 10) === w 439 + && parseInt(p.get('height') || '0', 10) === h 440 + && !p.has('maximized'); 441 + }, { w: preMaxWidth, h: preMaxHeight }); 442 + 443 + const finalState = await getHostState(restoredPageWindow); 444 + expect(parseInt(finalState.urlParams.width as string, 10), 445 + `unmaximize after save+restore should restore to original pre-maximize width, not workArea`) 446 + .toBe(preMaxWidth); 447 + expect(parseInt(finalState.urlParams.height as string, 10), 448 + `unmaximize after save+restore should restore to original pre-maximize height, not workArea`) 449 + .toBe(preMaxHeight); 450 + }); 260 451 });