experiments in a post-browser web
10
fork

Configure Feed

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

refactor(electron): delete extensionHostWindow + host HTML + broadcaster v1 branch (Phase 3.10)

Per v1-removal plan phase 3.10 (follows phase 3.9 which made extensionHostWindow a
dead empty shell — all built-ins migrated to v2 tiles via *-glue.ts).

Source deletes:
- Remove `extensionHostWindow` variable + `createExtensionHostWindow()` + `getExtensionHostWindow()` from main.ts
- Remove `await createExtensionHostWindow()` call from `loadExtensions()`
- Delete v1 iframe iteration loop from `extensionBroadcaster` (keep only v2 tile path)
- Simplify `loadLazyExtension()` to a no-op + warning (host gone; all v2 tiles load eagerly)
- Remove `getExtensionHostWindow` import from ipc.ts
- Drop `extension-host.html` entry from `INTERNAL_URLS` in ipc.ts
- Remove `extension-host.html` filter from `trackVisibleWindowFocus` + overlay hide loop in ipc.ts
- Delete `app/extension-host.html` file

Smoke test cleanup (per 3.1 audit recommendations):
- Delete tests: "extension host window exists for built-in extensions" (was 4174-4179)
- Delete tests: "built-in extensions load as iframes in extension host" (was 4181-4217)
- Delete tests: "local shortcut from extension-host iframe roundtrip" (was 5291-5371)
- Delete tests: "global shortcut from extension-host iframe roundtrip" (was 5373-5433)
- Rewrite "correct window count for hybrid mode" — drop hostWindows filter/assertion
- Remove host-window wait from startup block (was 1380-1386)
- Rewrite `waitForHybridExtensions` fixture helper — gate on extensions API, not host DOM
- Update tests/README.md hybrid mode section to reflect v2 tile topology

Validation: tsc --noEmit clean. Electron tests skipped (budget).

+28 -503
-107
app/extension-host.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <title>Extension Host</title> 6 - <style> 7 - /* Hide iframes - they're background scripts */ 8 - body { 9 - margin: 0; 10 - padding: 0; 11 - overflow: hidden; 12 - } 13 - iframe { 14 - display: none; 15 - } 16 - </style> 17 - </head> 18 - <body> 19 - <div id="extensions"></div> 20 - <script> 21 - /** 22 - * Extension Host 23 - * 24 - * Loads extension background scripts as iframes within a single window. 25 - * Each iframe gets a unique origin (peek://cmd/, peek://groups/, etc.) 26 - * for isolation, while sharing a single renderer process for efficiency. 27 - * 28 - * With nodeIntegrationInSubFrames enabled, each iframe gets its own 29 - * preload script instance, so the standard window.app API works. 30 - * 31 - * Supports lazy loading: extensions can be loaded on-demand when their 32 - * commands are first invoked, with ready signaling via pubsub. 33 - */ 34 - 35 - const api = window.app; 36 - const extensionFrames = new Map(); // extId -> iframe element 37 - const loadedExtensions = new Set(); 38 - 39 - console.log('[ext:host] Extension host starting'); 40 - 41 - /** 42 - * Load an extension iframe and return a promise that resolves when it's loaded. 43 - */ 44 - function loadExtension(id, url) { 45 - return new Promise((resolve, reject) => { 46 - if (loadedExtensions.has(id)) { 47 - console.log('[ext:host] Extension already loaded:', id); 48 - resolve(); 49 - return; 50 - } 51 - 52 - console.log('[ext:host] Loading extension:', id, url); 53 - 54 - const iframe = document.createElement('iframe'); 55 - iframe.src = url; 56 - iframe.id = `ext-${id}`; 57 - // No sandbox - we want full preload access via nodeIntegrationInSubFrames 58 - 59 - iframe.onload = () => { 60 - console.log('[ext:host] Extension loaded:', id); 61 - // Signal to main process that this iframe is ready via direct IPC 62 - if (api.ipc && api.ipc.send) { 63 - api.ipc.send('ext:iframe-ready', { id }); 64 - } 65 - resolve(); 66 - }; 67 - 68 - iframe.onerror = (err) => { 69 - console.error('[ext:host] Extension failed to load:', id, err); 70 - reject(err); 71 - }; 72 - 73 - document.getElementById('extensions').appendChild(iframe); 74 - extensionFrames.set(id, iframe); 75 - loadedExtensions.add(id); 76 - }); 77 - } 78 - 79 - // Listen for extension load requests from main process via direct IPC 80 - if (api.ipc && api.ipc.on) { 81 - api.ipc.on('ext:load', (msg) => { 82 - const { id, url } = msg; 83 - loadExtension(id, url).catch(() => { 84 - // Error already logged in loadExtension 85 - }); 86 - }); 87 - 88 - // Listen for lazy load requests — load extension and signal when ready via pubsub 89 - api.ipc.on('ext:lazy-load', (msg) => { 90 - const { id, url } = msg; 91 - console.log('[ext:host] Lazy loading extension:', id); 92 - loadExtension(id, url).then(() => { 93 - // Signal ready back to main process via pubsub 94 - api.publish('ext:lazy-ready', { id }, api.scopes.GLOBAL); 95 - }).catch(() => { 96 - // Error already logged 97 - }); 98 - }); 99 - } else { 100 - console.error('[ext:host] api.ipc not available - cannot load extensions'); 101 - } 102 - 103 - // Report ready 104 - console.log('[ext:host] Extension host ready'); 105 - </script> 106 - </body> 107 - </html>
+7 -9
backend/electron/ipc.ts
··· 93 93 } from './protocol.js'; 94 94 95 95 import { 96 - getExtensionHostWindow, 97 96 getRunningExtensions, 98 97 getAllRegisteredExtensions, 99 98 reloadExtension, ··· 250 249 function trackVisibleWindowFocus(win: BrowserWindow): void { 251 250 const url = win.webContents.getURL(); 252 251 // Exclude internal background windows 253 - if (url === 'peek://app/background.html' || url === 'peek://app/extension-host.html') { 252 + if (url === 'peek://app/background.html') { 254 253 return; 255 254 } 256 255 // Exclude non-focusable windows (e.g., HUD overlay) ··· 1794 1793 } 1795 1794 1796 1795 // Determine opener window and whether it's a real content parent 1797 - // Background/infrastructure windows (background.html, extension-host.html) are 1798 - // not real parents — they're just the IPC sender. Only content windows count 1799 - // as parents for IZUI child-window semantics (ESC closes child, focuses parent). 1796 + // Background/infrastructure windows (background.html) are not real parents — 1797 + // they're just the IPC sender. Only content windows count as parents for 1798 + // IZUI child-window semantics (ESC closes child, focuses parent). 1800 1799 const openerWindow = BrowserWindow.fromWebContents(ev.sender); 1801 - const INTERNAL_URLS = ['peek://app/background.html', 'peek://app/extension-host.html']; 1800 + const INTERNAL_URLS = ['peek://app/background.html']; 1802 1801 const openerUrl = openerWindow && !openerWindow.isDestroyed() ? openerWindow.webContents?.getURL() ?? '' : ''; 1803 1802 const isRealParent = openerWindow && !openerWindow.isDestroyed() && !INTERNAL_URLS.some(u => openerUrl === u); 1804 1803 ··· 2891 2890 const otherInfo = getWindowInfo(otherWin.id); 2892 2891 const otherAddress = otherInfo?.params?.address as string | undefined; 2893 2892 if (otherAddress?.includes('background.html')) continue; 2894 - if (otherAddress?.includes('extension-host.html')) continue; 2895 2893 // Hide and track 2896 2894 otherWin.hide(); 2897 2895 hiddenWindowIds.push(otherWin.id); ··· 3687 3685 // ev.reply() internally uses sender.sendToFrame(frameId, ...) which correctly 3688 3686 // handles both: 3689 3687 // - Normal BrowserWindow senders (main frame) 3690 - // - Iframe sub-frame senders (consolidated extensions in extension-host.html) 3688 + // - Sub-frame senders (for forward compatibility) 3691 3689 // 3692 - // We guard against destroyed senders (extension reload, iframe removed from DOM) 3690 + // We guard against destroyed senders (extension reload) 3693 3691 // to prevent uncaught exceptions. The previous approaches that failed: 3694 3692 // - BrowserWindow.fromId(ev.sender.id): wrong ID space (WebContents vs Window) 3695 3693 // - BrowserWindow.fromWebContents(ev.sender): returns null for iframe senders
+11 -151
backend/electron/main.ts
··· 70 70 const _sessionRestoreDonePromise = new Promise<void>(resolve => { _sessionRestoreDoneResolve = resolve; }); 71 71 let _sessionRestoreDone = false; 72 72 73 - // Extension host window for built-in extensions (consolidated mode) 74 - let extensionHostWindow: BrowserWindow | null = null; 75 - 76 73 // Built-in extensions that load in consolidated mode (iframes) 77 74 // External extensions (including 'example') load in separate windows 78 75 // ··· 214 211 setExtensionBroadcaster((topic, msg, source) => { 215 212 const sourceOrigin = peekHost(source); 216 213 217 - // Broadcast to consolidated extension iframes (built-in extensions) 218 - if (extensionHostWindow && !extensionHostWindow.isDestroyed()) { 219 - try { 220 - const mainFrame = extensionHostWindow.webContents.mainFrame; 221 - for (const frame of mainFrame.framesInSubtree) { 222 - if (frame !== mainFrame && frame.url) { 223 - const frameHost = peekHost(frame.url); 224 - // Don't echo back to sender 225 - if (frameHost !== sourceOrigin) { 226 - frame.send(`pubsub:${topic}`, { 227 - ...(msg as object), 228 - source 229 - }); 230 - } 231 - } 232 - } 233 - } catch { 234 - // Window may have been destroyed between check and send (during shutdown) 235 - } 236 - } 237 - 238 214 // Broadcast to v2 tile BrowserWindows (launched by tile-launcher). 239 215 // Without this, v2 tiles that subscribe to global pubsub events (editor:open, 240 - // item:created, etc.) never receive them, because they're not in 241 - // extensionWindows or the consolidated extension host. 216 + // item:created, etc.) never receive them. 242 217 for (const tileWin of getAllTileWindows()) { 243 218 try { 244 219 const winHost = peekHost(tileWin.webContents.getURL()); ··· 714 689 715 690 /** 716 691 * Load a lazy extension on demand. 717 - * Sends ext:lazy-load to the extension host and waits for ext:lazy-ready. 718 - * Returns a promise that resolves when the extension is loaded and ready. 692 + * Phase 3.10: extensionHostWindow deleted — all consolidated extensions are 693 + * now v2 tiles launched eagerly. The lazy-load path is a no-op; it resolves 694 + * immediately so callers don't hang. If a true lazy-load mechanism is needed 695 + * in future it should target the v2 tile launcher, not the v1 iframe host. 719 696 */ 720 - const LAZY_LOAD_TIMEOUT_MS = 10000; 721 - 722 697 function loadLazyExtension(extId: string): Promise<void> { 723 698 if (lazyExtensionLoaded.has(extId)) { 724 699 return Promise.resolve(); 725 700 } 726 - 727 - return new Promise<void>((resolve) => { 728 - // Queue the callback 729 - if (!lazyLoadCallbacks.has(extId)) { 730 - lazyLoadCallbacks.set(extId, []); 731 - } 732 - lazyLoadCallbacks.get(extId)!.push(resolve); 733 - 734 - // Only send load request once (first callback triggers it) 735 - if (lazyLoadCallbacks.get(extId)!.length === 1) { 736 - // Safety timeout: if ext:ready never arrives, resolve all callbacks 737 - setTimeout(() => { 738 - if (lazyExtensionLoaded.has(extId)) return; // Already resolved normally 739 - console.error(`[ext:lazy] Timeout waiting for ext:ready from ${extId} (${LAZY_LOAD_TIMEOUT_MS}ms)`); 740 - lazyExtensionLoaded.add(extId); 741 - const callbacks = lazyLoadCallbacks.get(extId) || []; 742 - lazyLoadCallbacks.delete(extId); 743 - for (const cb of callbacks) { 744 - cb(); 745 - } 746 - }, LAZY_LOAD_TIMEOUT_MS); 747 - 748 - if (extensionHostWindow && !extensionHostWindow.isDestroyed()) { 749 - DEBUG && console.log(`[ext:lazy] Loading lazy extension: ${extId}`); 750 - // Create iframe directly via executeJavaScript (bypasses IPC/contextBridge) 751 - extensionHostWindow.webContents.executeJavaScript(` 752 - (function() { 753 - if (document.getElementById('ext-${extId}')) return; 754 - var iframe = document.createElement('iframe'); 755 - iframe.src = 'peek://${extId}/background.html'; 756 - iframe.id = 'ext-${extId}'; 757 - document.getElementById('extensions').appendChild(iframe); 758 - })(); 759 - `).catch((err) => { 760 - console.error(`[ext:lazy] Failed to create iframe for ${extId}:`, err); 761 - }); 762 - } else { 763 - console.error(`[ext:lazy] Extension host not available for lazy loading: ${extId}`); 764 - resolve(); // Resolve anyway to avoid hanging 765 - } 766 - } 767 - }); 701 + // No extension host window — resolve immediately (v2 tiles load eagerly). 702 + console.warn(`[ext:lazy] loadLazyExtension('${extId}') called but extensionHostWindow has been removed. If this extension needs lazy loading, migrate it to a v2 tile.`); 703 + lazyExtensionLoaded.add(extId); 704 + return Promise.resolve(); 768 705 } 769 706 770 707 /** ··· 1080 1017 } 1081 1018 } 1082 1019 1083 - /** 1084 - * Create the consolidated extension host window 1085 - * All extensions load as iframes within this single window 1086 - */ 1087 - async function createExtensionHostWindow(): Promise<BrowserWindow> { 1088 - DEBUG && console.log('[ext:host] Creating consolidated extension host window'); 1089 - 1090 - // Use profile-specific session for isolation 1091 - const profileSession = getProfileSession(); 1092 - 1093 - const win = new BrowserWindow({ 1094 - show: false, 1095 - backgroundColor: getSystemThemeBackgroundColor(), 1096 - webPreferences: { 1097 - preload: config.preloadPath, 1098 - session: profileSession, 1099 - nodeIntegrationInSubFrames: true, // Preload runs in iframes too 1100 - // Disable same-origin policy so extension iframes can fetch() cross-origin. 1101 - // Required by entities extension which fetches page content for extraction. 1102 - // All iframes only load trusted extension code (peek://ext/{id}/...). 1103 - webSecurity: false, 1104 - } 1105 - }); 1106 - 1107 - // Forward console logs from extension host 1108 - win.webContents.on('console-message', (event) => { 1109 - DEBUG && console.log(`[ext:host] ${event.message}`); 1110 - }); 1111 - 1112 - // Log load errors 1113 - win.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => { 1114 - console.error(`[ext:host] Failed to load: ${errorCode} ${errorDescription}`); 1115 - }); 1116 - 1117 - // Track iframe loading via did-frame-finish-load events 1118 - // This fires for each sub-frame (iframe) when it finishes loading 1119 - win.webContents.on('did-frame-finish-load', (_event, isMainFrame) => { 1120 - if (isMainFrame) return; // Skip the extension host page itself 1121 - // Check all frames to see which extensions have loaded 1122 - try { 1123 - const frames = win.webContents.mainFrame.frames; 1124 - for (const frame of frames) { 1125 - const url = frame.url; 1126 - // Extract extension ID from peek://{extId}/background.html 1127 - const match = url.match(/^peek:\/\/([^/]+)\/background\.html/); 1128 - if (match) { 1129 - const extId = match[1]; 1130 - DEBUG && console.log(`[ext:host] Frame loaded: ${extId}`); 1131 - } 1132 - } 1133 - } catch (err) { 1134 - // Frame enumeration can fail if window is being destroyed 1135 - DEBUG && console.log('[ext:host] Frame enumeration error:', err); 1136 - } 1137 - }); 1138 - 1139 - await win.loadURL('peek://app/extension-host.html'); 1140 - 1141 - extensionHostWindow = win; 1142 - 1143 - // Open devtools for extension host in debug mode (not in tests or headless) 1144 - if (config.isDev && !isTestProfile() && !isHeadless()) { 1145 - win.webContents.openDevTools({ mode: 'detach', activate: false }); 1146 - } 1147 - 1148 - return win; 1149 - } 1150 1020 1151 1021 /** 1152 1022 * Load all enabled extensions (hybrid mode) ··· 1173 1043 } 1174 1044 }); 1175 1045 1176 - // Load the extension host and cmd FIRST — cmd owns the command registry. 1046 + // Load cmd FIRST — cmd owns the command registry. 1177 1047 // Any feature that publishes cmd:register or cmd:register-batch must find 1178 1048 // cmd already subscribed, otherwise registration is lost. 1179 - // cmd now runs as a standalone tile renderer (tile-preload + trustedBuiltin 1180 - // grant) — no longer loaded as an iframe in the consolidated extension host. 1181 - await createExtensionHostWindow(); 1049 + // cmd runs as a standalone tile renderer (tile-preload + trustedBuiltin grant). 1182 1050 await initCmd({ tilePreloadPath }); 1183 1051 1184 1052 // Load HUD right after cmd — it's core app infrastructure like cmd, and ··· 1346 1214 } 1347 1215 1348 1216 return result; 1349 - } 1350 - 1351 - 1352 - /** 1353 - * Get the extension host window (for consolidated/builtin extensions) 1354 - */ 1355 - export function getExtensionHostWindow(): BrowserWindow | null { 1356 - return extensionHostWindow; 1357 1217 } 1358 1218 1359 1219
+5 -6
tests/README.md
··· 137 137 138 138 | Test | What it verifies | 139 139 |------|-----------------| 140 - | `extension host window exists` | Extension host window loads at `peek://app/extension-host.html` | 141 - | `built-in extensions load as iframes` | `cmd`, `groups`, `peeks`, `slides` are iframes in extension host | 140 + | `v2 background tile windows exist` | v2 tile background windows launch at `peek://{id}/background.html` | 142 141 | `example extension loads as separate window` | External extensions get separate BrowserWindows | 143 142 | `commands work from both consolidated and external` | Commands from both loading modes are accessible | 144 143 | `pubsub works between consolidated and external` | Cross-extension messaging works across loading modes | 145 - | `correct window count for hybrid mode` | Expected window count: 1 core + 1 host + 1 external | 144 + | `correct window count for hybrid mode` | Expected window count: 1 core bg + N v2 tile windows + 1 external | 146 145 147 - The test fixture (`tests/fixtures/desktop-app.ts`) handles hybrid mode by: 148 - 1. Waiting for the extension host window to load 149 - 2. Waiting for at least one external extension window (e.g., `example`) 146 + The test fixture (`tests/fixtures/desktop-app.ts`) handles v2-tile mode by: 147 + 1. Waiting for the test-fixture renderer window (`peek://test/`) to load 148 + 2. Polling the extensions API until cmd is running and at least 3 extensions are loaded 150 149 3. Providing `getExtensionWindows()` that returns separate window extensions only 151 150 152 151 ## Coverage Matrix
+2 -202
tests/desktop/smoke.spec.ts
··· 1372 1372 test.describe('Core Functionality @desktop', () => { 1373 1373 test('app launches and extensions load', async () => { 1374 1374 // After v2 tile migration: 1375 - // - Legacy v1 extensions (cmd, hud, page) load as iframes in extension-host 1376 1375 // - V2 features load as separate background BrowserWindows (peek://{id}/background.html) 1377 1376 // - Eager v2 features (e.g. entities, peeks, slides) launch at startup; 1378 1377 // lazy v2 features (e.g. example) launch on first command/event 1379 - 1380 - // Check extension host exists (still used for legacy v1 iframes including cmd) 1381 - const hostWindow = await waitForWindow( 1382 - () => sharedApp.windows(), 1383 - 'peek://app/extension-host.html', 1384 - 15000 1385 - ); 1386 - expect(hostWindow).toBeDefined(); 1387 1378 1388 1379 // Check that at least one eager v2 background tile window exists. 1389 1380 // peeks and slides are eager v2 background tiles that launch at startup. ··· 4171 4162 bgWindow = sharedBgWindow; 4172 4163 }); 4173 4164 4174 - test('extension host window exists for built-in extensions', async () => { 4175 - // Extension host should exist for consolidated built-in extensions 4176 - const windows = sharedApp.windows(); 4177 - const hostWindow = windows.find(w => w.url().includes('peek://app/extension-host.html')); 4178 - expect(hostWindow).toBeDefined(); 4179 - }); 4180 - 4181 - test('built-in extensions load as iframes in extension host', async () => { 4182 - // After v2 tile migration: 4183 - // - Only legacy v1 consolidated extensions (cmd, hud, page) load as iframes 4184 - // inside the extension-host window. cmd is loaded by cmd-glue.ts. 4185 - // - V2 features (peeks, slides, groups, editor, etc.) now launch as 4186 - // separate background BrowserWindows at peek://{id}/background.html and 4187 - // are NOT iframes in the host. See "v2 background tile windows" test below. 4188 - const windows = sharedApp.windows(); 4189 - const hostWindow = windows.find(w => w.url().includes('peek://app/extension-host.html')); 4190 - expect(hostWindow).toBeDefined(); 4191 - 4192 - // Wait for #extensions container to exist (it may be hidden, so use 'attached' state) 4193 - await hostWindow!.waitForSelector('#extensions', { timeout: 15000, state: 'attached' }); 4194 - 4195 - // Wait for at least the cmd iframe to load 4196 - await hostWindow!.waitForFunction( 4197 - () => { 4198 - const container = document.getElementById('extensions'); 4199 - const iframes = container ? container.querySelectorAll('iframe') : []; 4200 - return Array.from(iframes).some(f => (f as HTMLIFrameElement).src.includes('peek://cmd/')); 4201 - }, 4202 - { timeout: 15000 } 4203 - ); 4204 - 4205 - const iframeData = await hostWindow!.evaluate(() => { 4206 - const container = document.getElementById('extensions'); 4207 - const iframes = container ? Array.from(container.querySelectorAll('iframe')) : []; 4208 - return { 4209 - count: iframes.length, 4210 - srcs: iframes.map(f => f.src) 4211 - }; 4212 - }); 4213 - 4214 - // cmd is always loaded as an iframe in the extension host (via cmd-glue.ts) 4215 - expect(iframeData.count).toBeGreaterThanOrEqual(1); 4216 - expect(iframeData.srcs.some(s => s.includes('peek://cmd/'))).toBe(true); 4217 - }); 4218 - 4219 4165 test('v2 background tile windows exist as separate BrowserWindows', async () => { 4220 4166 // V2 background tiles (peeks, slides) launch as separate hidden BrowserWindows 4221 4167 // at peek://{id}/background.html — NOT as iframes in the extension host. ··· 4340 4286 }); 4341 4287 4342 4288 test('correct window count for hybrid mode', async () => { 4343 - // In hybrid mode we should have: 4289 + // In v2-tile mode we should have: 4344 4290 // - 1 background window (core) 4345 - // - 1 extension host window (consolidated built-ins) 4291 + // - v2 tile background windows (one per feature) 4346 4292 // - 1 separate window for 'example' extension 4347 4293 // - Plus any UI windows (settings, etc.) 4348 4294 ··· 4356 4302 const windows = sharedApp.windows(); 4357 4303 4358 4304 const bgWindows = windows.filter(w => w.url().includes('app/background.html')); 4359 - const hostWindows = windows.filter(w => w.url().includes('extension-host.html')); 4360 4305 // Filter to only count 'example' extension windows (the only external extension) 4361 4306 const exampleExtWindows = windows.filter(w => 4362 4307 w.url().includes('peek://ext/example/') && w.url().includes('background.html') 4363 4308 ); 4364 4309 4365 4310 expect(bgWindows.length).toBe(1); 4366 - expect(hostWindows.length).toBe(1); 4367 4311 // Only example should be in separate window 4368 4312 expect(exampleExtWindows.length).toBe(1); 4369 4313 expect(exampleExtWindows[0].url()).toContain('example'); ··· 5285 5229 await bgWindow.evaluate(() => { 5286 5230 (window as any).app.shortcuts.unregister('Alt+F7'); 5287 5231 delete (window as any).__shortcutFired; 5288 - }); 5289 - }); 5290 - 5291 - test('local shortcut from extension-host iframe roundtrip', async () => { 5292 - // Register a local shortcut from a consolidated extension iframe (in extension-host.html), 5293 - // trigger it via handleLocalShortcut in the main process, verify callback fires 5294 - // in the iframe. This tests ev.reply for iframe WebContents where 5295 - // BrowserWindow.fromWebContents() returns null. 5296 - const bgWindow = sharedBgWindow; 5297 - 5298 - // Find the extension host window 5299 - const hostWindow = sharedApp.windows().find(w => w.url().includes('extension-host.html')); 5300 - expect(hostWindow).toBeDefined(); 5301 - 5302 - // Wait for iframes to load 5303 - await hostWindow!.waitForFunction( 5304 - () => { 5305 - const container = document.getElementById('extensions'); 5306 - const iframes = container ? container.querySelectorAll('iframe') : []; 5307 - return iframes.length >= 1; 5308 - }, 5309 - { timeout: 10000 } 5310 - ); 5311 - 5312 - // After v2 tile migration, slides/groups/peeks are now separate v2 background 5313 - // BrowserWindows — not iframes in the host. The remaining v1 iframes in the 5314 - // extension host are cmd (loaded by cmd-glue.ts) and hud (eager v1 consolidated). 5315 - // Use the cmd iframe since it is always loaded. 5316 - const frames = hostWindow!.frames(); 5317 - const extFrame = frames.find(f => 5318 - f.url().includes('peek://cmd/') || f.url().includes('peek://hud/') 5319 - ); 5320 - expect(extFrame).toBeDefined(); 5321 - 5322 - // Register a local shortcut from the iframe context 5323 - await extFrame!.evaluate(() => { 5324 - (window as any).__iframeShortcutFired = false; 5325 - (window as any).app.shortcuts.register('Alt+F8', () => { 5326 - (window as any).__iframeShortcutFired = true; 5327 - }); 5328 - }); 5329 - 5330 - // Wait for IPC registration to reach main process 5331 - await sleep(300); 5332 - 5333 - // Trigger the shortcut from the main process. 5334 - // See local-shortcut test above for rationale on the catch-destroyed shim. 5335 - const handled = await sharedApp.evaluateMain!(({ app }) => { 5336 - try { 5337 - const { handleLocalShortcut } = (globalThis as any).__peek_test; 5338 - const result = handleLocalShortcut({ 5339 - type: 'keyDown', 5340 - alt: true, 5341 - shift: false, 5342 - meta: false, 5343 - control: false, 5344 - code: 'F8' 5345 - }); 5346 - return !!result; 5347 - } catch (e: any) { 5348 - return 'peek_test-failed: ' + e.message; 5349 - } 5350 - }).catch((err: any) => { 5351 - if (/context was destroyed/i.test(err?.message || '')) return true; 5352 - throw err; 5353 - }); 5354 - 5355 - expect(handled).toBe(true); 5356 - 5357 - // Wait for the reply to reach the iframe and trigger the callback 5358 - await extFrame!.waitForFunction( 5359 - () => (window as any).__iframeShortcutFired === true, 5360 - { timeout: 5000 } 5361 - ); 5362 - 5363 - const fired = await extFrame!.evaluate(() => (window as any).__iframeShortcutFired); 5364 - expect(fired).toBe(true); 5365 - 5366 - // Clean up 5367 - await extFrame!.evaluate(() => { 5368 - (window as any).app.shortcuts.unregister('Alt+F8'); 5369 - delete (window as any).__iframeShortcutFired; 5370 - }); 5371 - }); 5372 - 5373 - test('global shortcut from extension-host iframe roundtrip', async () => { 5374 - // Register a global shortcut from a consolidated extension iframe, 5375 - // trigger it from the main process, verify callback fires in the iframe. 5376 - // This is the exact scenario that breaks slides extension hotkeys. 5377 - 5378 - // Find the extension host window 5379 - const hostWindow = sharedApp.windows().find(w => w.url().includes('extension-host.html')); 5380 - expect(hostWindow).toBeDefined(); 5381 - 5382 - // After v2 tile migration, slides/groups/peeks are now separate v2 background 5383 - // BrowserWindows — not iframes in the host. Use a remaining v1 iframe (cmd or hud). 5384 - const frames = hostWindow!.frames(); 5385 - const extFrame = frames.find(f => 5386 - f.url().includes('peek://cmd/') || f.url().includes('peek://hud/') 5387 - ); 5388 - expect(extFrame).toBeDefined(); 5389 - 5390 - // Register a GLOBAL shortcut from the iframe context 5391 - await extFrame!.evaluate(() => { 5392 - (window as any).__globalIframeShortcutFired = false; 5393 - (window as any).app.shortcuts.register('CommandOrControl+Shift+F11', () => { 5394 - (window as any).__globalIframeShortcutFired = true; 5395 - }, { global: true }); 5396 - }); 5397 - 5398 - // Wait for IPC registration to propagate 5399 - await sleep(300); 5400 - 5401 - // Verify the shortcut was registered in the main process. 5402 - // Wrap in catch-destroyed shim (same rationale as local shortcut tests). 5403 - const isRegistered = await sharedApp.evaluateMain!(({ app: electronApp }) => { 5404 - // Access globalShortcut from the electron app module context 5405 - const electron = (globalThis as any).__peek_electron || {}; 5406 - if (electron.globalShortcut) { 5407 - return electron.globalShortcut.isRegistered('CommandOrControl+Shift+F11'); 5408 - } 5409 - // Fallback: check via the imported shortcuts module 5410 - return 'no-globalShortcut-access'; 5411 - }).catch((err: any) => { 5412 - // If we can't verify due to context destruction, assume registered — 5413 - // register flow is covered by the local shortcut tests. 5414 - if (/context was destroyed/i.test(err?.message || '')) return true; 5415 - throw err; 5416 - }); 5417 - expect(isRegistered).toBe(true); 5418 - 5419 - // Trigger the global shortcut callback. Electron's globalShortcut.register stores 5420 - // a callback. We simulate the OS-level trigger by emitting the accelerator. 5421 - // Since we can't trigger OS-level shortcuts from Playwright, we use a workaround: 5422 - // call the internal shortcut callback via the electron module. 5423 - // Unfortunately globalShortcut has no programmatic trigger API. 5424 - // The real test is that the global shortcut was registered (above) and that the 5425 - // ev.reply mechanism works (tested in the local shortcut tests with same sender). 5426 - // Here we verify the registration path works for iframe senders. 5427 - 5428 - // Clean up 5429 - await extFrame!.evaluate(() => { 5430 - (window as any).app.shortcuts.unregister('CommandOrControl+Shift+F11', { global: true }); 5431 - delete (window as any).__globalIframeShortcutFired; 5432 5232 }); 5433 5233 }); 5434 5234
+3 -28
tests/fixtures/desktop-app.ts
··· 217 217 const bgWindow = await waitForWindowHelper(() => electronApp.windows(), 'peek://test/', 30000); 218 218 await waitForAppReady(bgWindow, 15000); 219 219 220 - // Wait for extensions to be ready via the API (checks actual extension system state) 221 - // This replaces window-counting which was unreliable with lazy loading 220 + // Wait for extensions to be ready via the API (checks actual extension system state). 221 + // Gates on v2 tile readiness via the extensions API — no host window dependency. 222 222 const waitForHybridExtensions = async (timeout: number): Promise<void> => { 223 223 const start = Date.now(); 224 224 while (Date.now() - start < timeout) { 225 - // Check for extension host window (consolidated built-ins) 226 - const hostWindow = electronApp.windows().find(w => 227 - w.url().includes('peek://app/extension-host.html') 228 - ); 229 - 230 - if (!hostWindow) { 231 - await sleep(100); 232 - continue; 233 - } 234 - 235 - // Verify host window's DOM is actually ready 236 - try { 237 - const hostReady = await hostWindow.evaluate(() => { 238 - return document.readyState === 'complete' && 239 - document.getElementById('extensions') !== null; 240 - }); 241 - if (!hostReady) { 242 - await sleep(100); 243 - continue; 244 - } 245 - } catch { 246 - await sleep(100); 247 - continue; 248 - } 249 - 250 225 // Check via API that critical extensions are loaded 251 226 try { 252 227 const ready = await bgWindow.evaluate(async () => { ··· 271 246 // Timeout reached - throw error with diagnostic info 272 247 const windows = electronApp.windows(); 273 248 const urls = windows.map(w => w.url()); 274 - throw new Error(`Hybrid extensions failed to load within ${timeout}ms. Windows: ${JSON.stringify(urls)}`); 249 + throw new Error(`Extensions failed to load within ${timeout}ms. Windows: ${JSON.stringify(urls)}`); 275 250 }; 276 251 await waitForHybridExtensions(15000); 277 252