experiments in a post-browser web
10
fork

Configure Feed

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

wip(chrome-ext): runtime-external polyfill checkpoint + harness diagnostics

Checkpoint of in-progress runtime-external work. Not load-bearing for any
shipping feature; the architecture documented here is incomplete and the
flow it targets (Proton Pass /auth-ext) still fails. Landing it so the
diagnostic instrumentation isn't lost.

Polyfill state:
- Page-side preload (runtime-external-preload.cjs) is in REDACT mode:
removes chrome.runtime.sendMessage on externally_connectable origins to
force webextension-polyfill-style senders down their postMessage +
content-script path. Verified by harness; the orchestrator content
script either isn't injected or doesn't forward to the BG SW, so the
redacted path produces the same Oops the native path does.
- The else branch that wholesale-replaced window.chrome.runtime with a
synthetic object has been removed. It was racing Chromium's
native_extension_bindings_system bindings install and producing
`ERROR:extensions/renderer/native_extension_bindings_system.cc:712
Failed to create API on Chrome object` log lines (one per API
namespace) on every preload attach. The redact-only path is the one
we want.
- Main-side runtime-external.js retains origin-fallback resolution
(findExtensionByOrigin) and per-SW listener attachment via
session.serviceWorkers.getWorkerFromVersionID — both reusable when the
bridge-page architecture lands.
- BG SW shim keeps the native-dispatch logging wrapper around captured
onMessageExternal listeners. It confirmed that native cross-origin
sendMessage dispatch in Electron 40 does not deliver to BG SW
listeners, even with the polyfill disabled.

Right architecture (untested, for next session): hidden BrowserWindow
loading a chrome-extension://<id>/peek-bridge.html page. Extension pages
have both ipcRenderer (regular renderer surface) and same-extension
chrome.runtime.connect, so they can pump main-process IPC into the BG
SW via a long-lived port — neither of which the BG SW context exposes.

PEEK_DISABLE_RUNTIME_EXTERNAL_POLYFILL=1 (entry.ts) skips installation
entirely for side-by-side testing.

Harness improvements:
- tests/manual/harness/tracer.ts: streams events to disk via a 500ms
drain so traces survive app shutdown; captures main-process
stdout/stderr; captures SW console + registration + running-status
events on the profile partition.
- tests/fixtures/desktop-app.ts: stable persistent profile path for
test-harness-* profiles in the manual project so login state survives
across runs; cleanupAllTempDirs skips them.

+424 -142
+6 -1
backend/electron/chrome-api-polyfills/index.d.ts
··· 1 - export function registerAll(session: any, options?: { ipcMain?: any; app?: any }): { 1 + export function registerAll(session: any, options?: { 2 + ipcMain?: any; 3 + app?: any; 4 + apis?: string[]; 5 + findExtensionByOrigin?: (origin: string) => string | null; 6 + }): { 2 7 cleanup: () => void; 3 8 getPreloadScript: () => string; 4 9 };
+2 -2
backend/electron/chrome-api-polyfills/index.js
··· 59 59 * }} 60 60 */ 61 61 export function registerAll(session, options = {}) { 62 - const { ipcMain, app, apis } = options; 62 + const { ipcMain, app, apis, findExtensionByOrigin } = options; 63 63 64 64 if (!ipcMain) { 65 65 throw new Error('chrome-api-polyfills: options.ipcMain is required'); ··· 96 96 } 97 97 98 98 if (shouldRegister('runtimeExternal')) { 99 - handles.runtimeExternal = runtimeExternal.setupMainProcess(session, { ipcMain }); 99 + handles.runtimeExternal = runtimeExternal.setupMainProcess(session, { ipcMain, findExtensionByOrigin }); 100 100 cleanups.push(handles.runtimeExternal.cleanup); 101 101 } 102 102
+43 -20
backend/electron/chrome-api-polyfills/runtime-external-preload.cjs
··· 108 108 }; 109 109 110 110 if (!window.chrome) window.chrome = {}; 111 - // Force-override even if Electron pre-exposed chrome.runtime — its native 112 - // stub for externally-connectable origins doesn't actually round-trip to 113 - // onMessageExternal in our setup. defineProperty defeats non-writable 114 - // prior definitions. 115 - try { 116 - Object.defineProperty(window.chrome, 'runtime', { 117 - value: _rt, writable: true, configurable: true, enumerable: true, 118 - }); 119 - console.log('[peek:runtime-external:page] runtime replaced via defineProperty'); 120 - } catch (e1) { 121 - try { window.chrome.runtime = _rt; 122 - console.log('[peek:runtime-external:page] runtime replaced via assignment'); 111 + 112 + // Strategy: don't try to bridge cross-origin sendMessage ourselves — 113 + // Electron 40's SW context has no electron.ipcRenderer, so a main-process 114 + // bridge can't deliver messages back into the BG SW. Instead, force 115 + // Proton's webextension-polyfill-style sender (module 57118) to fall back 116 + // to its postMessage path, which routes through Proton Pass's own 117 + // \`orchestrator.js\` content script — that script uses 118 + // chrome.runtime.sendMessage (same-extension), which Electron handles 119 + // natively. We remove sendMessage from chrome.runtime; module 57118's 120 + // branch \`Rw.runtime.sendMessage !== void 0\` becomes false, taking 121 + // Path B. We KEEP chrome.runtime.id, getURL, getManifest, etc. since 122 + // Proton reads those at module init. 123 + function _redactSendMessage(rt) { 124 + if (!rt) return; 125 + try { Object.defineProperty(rt, 'sendMessage', { value: undefined, configurable: true, writable: true, enumerable: true }); } 126 + catch (e) { 127 + try { rt.sendMessage = undefined; } catch (_) {} 123 128 } 124 - catch (e2) { 125 - // Last resort: patch only sendMessage. Other props left in place. 126 - try { window.chrome.runtime.sendMessage = _sendMessage; 127 - console.log('[peek:runtime-external:page] only sendMessage replaced'); 128 - } catch(e3) { 129 - console.error('[peek:runtime-external:page] install failed defineProp=', e1 && e1.message, 'assign=', e2 && e2.message, 'partial=', e3 && e3.message); 130 - } 131 - } 129 + } 130 + 131 + // Set extension id on native chrome.runtime so callers reading 132 + // chrome.runtime.id get the correct hash; native exposes id=null otherwise. 133 + function _setExtId(rt) { 134 + try { Object.defineProperty(rt, 'id', { value: __PEEK_EXT_ID__, configurable: true, writable: true, enumerable: true }); } 135 + catch (e) { try { rt.id = __PEEK_EXT_ID__; } catch (_) {} } 136 + } 137 + 138 + if (window.chrome.runtime) { 139 + _redactSendMessage(window.chrome.runtime); 140 + _setExtId(window.chrome.runtime); 141 + console.log('[peek:runtime-external:page] forced Path B: chrome.runtime.sendMessage redacted, id set, native otherwise preserved'); 142 + } 143 + // If window.chrome.runtime isn't there yet, do nothing. Replacing it with a 144 + // synthetic object races Chromium's native_extension_bindings_system trying 145 + // to install its own APIs and surfaces as 146 + // "ERROR:extensions/renderer/native_extension_bindings_system.cc:712 147 + // Failed to create API on Chrome object." The redact branch above is the 148 + // only path we need — Chromium will create chrome.runtime natively when 149 + // the page actually qualifies. 150 + 151 + if (window.browser && window.browser.runtime) { 152 + _redactSendMessage(window.browser.runtime); 153 + _setExtId(window.browser.runtime); 154 + console.log('[peek:runtime-external:page] browser.runtime sendMessage also redacted'); 132 155 } 133 156 })();`; 134 157
+180 -92
backend/electron/chrome-api-polyfills/runtime-external.js
··· 101 101 function _newReqId() { 102 102 return `peek-rt-${Date.now()}-${++_seq}`; 103 103 } 104 + const _attachedSwIpc = new Set(); 104 105 105 106 /** 106 107 * Register main-process handlers for the runtime-external bridge. ··· 108 109 * @param {import('electron').Session} session — Electron session that has the extension(s) loaded 109 110 * @param {Object} options 110 111 * @param {import('electron').IpcMain} options.ipcMain 112 + * @param {(url: string) => string | null} [options.findExtensionByOrigin] — fallback resolver for when the requested extId doesn't match a running SW. Receives the request origin and returns an extension ID whose `externally_connectable.matches` accepts that origin (or null). Used to bridge the gap between the hardcoded Web Store IDs in pages like Proton's account bundle (which fans out to 4 platform-specific IDs) and the Electron-assigned runtime hash of our locally-unpacked extension. 111 113 * @returns {{ cleanup: () => void }} 112 114 */ 113 115 export function setupMainProcess(session, options = {}) { 114 - const { ipcMain } = options; 116 + const { ipcMain, findExtensionByOrigin } = options; 115 117 if (!ipcMain) { 116 118 throw new Error('chrome-api-polyfills/runtime-external: options.ipcMain is required'); 117 119 } 118 120 119 121 // Receive page-side request from the webview preload. 120 122 ipcMain.handle(IPC_SEND_FROM_PAGE, async (_event, payload) => { 121 - const { extId, message, origin } = payload || {}; 122 - console.log('[peek:runtime-external:main] IPC received extId=', extId, 'type=', message && message.type, 'origin=', origin); 123 - if (!extId || typeof extId !== 'string') { 123 + const { extId: requestedExtId, message, origin } = payload || {}; 124 + console.log('[peek:runtime-external:main] IPC received extId=', requestedExtId, 'type=', message && message.type, 'origin=', origin); 125 + if (!requestedExtId || typeof requestedExtId !== 'string') { 124 126 return { __peekRuntimeExternalError: 'missing extId' }; 125 127 } 126 128 ··· 131 133 const swInfos = (session.serviceWorkers?.getAllRunning?.() ?? {}); 132 134 const swSummary = Object.entries(swInfos).map(([v, i]) => `vid=${v} scope=${i?.scope}`).join(' ; '); 133 135 console.log('[peek:runtime-external:main] running service workers:', swSummary || '(none)'); 134 - let targetVersionId = null; 135 - for (const [vid, info] of Object.entries(swInfos)) { 136 - const scope = info?.scope ?? ''; 137 - if (scope.startsWith(`chrome-extension://${extId}/`)) { 138 - targetVersionId = Number(vid); 139 - break; 136 + function findVersionIdForExtId(eid) { 137 + for (const [vid, info] of Object.entries(swInfos)) { 138 + const scope = info?.scope ?? ''; 139 + if (scope.startsWith(`chrome-extension://${eid}/`)) return Number(vid); 140 + } 141 + return null; 142 + } 143 + let targetVersionId = findVersionIdForExtId(requestedExtId); 144 + let extId = requestedExtId; 145 + if (targetVersionId == null && typeof findExtensionByOrigin === 'function' && origin) { 146 + // The requested extId didn't match. Pages like Proton's account bundle 147 + // fan out to multiple platform-specific Web Store IDs (Chrome, Edge, 148 + // Firefox, Safari) — none match our Electron-assigned runtime hash. 149 + // Resolve by origin instead: any extension whose 150 + // externally_connectable.matches accepts this origin is a valid target. 151 + try { 152 + const resolvedExtId = findExtensionByOrigin(origin); 153 + if (resolvedExtId) { 154 + const vid = findVersionIdForExtId(resolvedExtId); 155 + if (vid != null) { 156 + console.log('[peek:runtime-external:main] origin-fallback: requested extId=', requestedExtId, 'resolved to extId=', resolvedExtId, 'via origin=', origin); 157 + targetVersionId = vid; 158 + extId = resolvedExtId; 159 + } 160 + } 161 + } catch (e) { 162 + console.error('[peek:runtime-external:main] findExtensionByOrigin threw:', e?.message); 140 163 } 141 164 } 142 165 if (targetVersionId == null) { 143 - console.error('[peek:runtime-external:main] no SW match for extId=', extId); 144 - return { __peekRuntimeExternalError: `no running service worker for extension ${extId}` }; 166 + console.error('[peek:runtime-external:main] no SW match for extId=', requestedExtId, '(origin fallback also failed)'); 167 + return { __peekRuntimeExternalError: `no running service worker for extension ${requestedExtId}` }; 145 168 } 146 169 147 170 const reqId = _newReqId(); ··· 169 192 }); 170 193 171 194 try { 172 - session.serviceWorkers.send(targetVersionId, SW_CHANNEL_INCOMING, { reqId, message, sender }); 173 - console.log('[peek:runtime-external:main] serviceWorkers.send dispatched reqId=', reqId); 195 + // Electron 40: use ServiceWorkerMain.send via getWorkerFromVersionID 196 + // — there is no generic send() on the ServiceWorkers manager. 197 + const swMain = session.serviceWorkers.getWorkerFromVersionID?.(targetVersionId) 198 + ?? session.serviceWorkers.fromVersionID?.(targetVersionId); 199 + if (!swMain || typeof swMain.send !== 'function') { 200 + throw new Error('ServiceWorkerMain.send not available'); 201 + } 202 + // Lazily attach the response listener on this swMain's ipc — first 203 + // dispatch to a given SW wires it up; subsequent dispatches reuse. 204 + if (!_attachedSwIpc.has(targetVersionId) && swMain.ipc?.on) { 205 + _attachedSwIpc.add(targetVersionId); 206 + swMain.ipc.on(SW_CHANNEL_RESPONSE, (_e, payload) => { 207 + const { reqId: rid, response, error } = payload || {}; 208 + const pending = _pendingByReqId.get(rid); 209 + if (pending) { 210 + _pendingByReqId.delete(rid); 211 + pending(error ? { __peekRuntimeExternalError: error } : response); 212 + } else { 213 + console.error('[peek:runtime-external:main] response for unknown reqId=', rid); 214 + } 215 + }); 216 + console.log('[peek:runtime-external:main] swMain.ipc listener attached for vid=', targetVersionId); 217 + } 218 + swMain.send(SW_CHANNEL_INCOMING, { reqId, message, sender }); 219 + console.log('[peek:runtime-external:main] swMain.send dispatched reqId=', reqId); 174 220 } catch (err) { 175 221 _pendingByReqId.delete(reqId); 176 222 clearTimeout(timer); 177 - console.error('[peek:runtime-external:main] serviceWorkers.send THREW reqId=', reqId, 'err=', err?.message); 178 - resolve({ __peekRuntimeExternalError: `serviceWorkers.send failed: ${err?.message ?? String(err)}` }); 223 + console.error('[peek:runtime-external:main] swMain.send THREW reqId=', reqId, 'err=', err?.message); 224 + resolve({ __peekRuntimeExternalError: `swMain.send failed: ${err?.message ?? String(err)}` }); 179 225 } 180 226 }); 181 227 }); 182 228 183 - // SW → main reply path. The BG SW's shim posts via globalThis.postMessage 184 - // which Electron emits as 'ipc-message' on session.serviceWorkers. The 185 - // exact event shape is Electron-version-sensitive — the listener below 186 - // tries the most common forms. 187 - const onIpcMessage = (event, ...rest) => { 188 - // Electron 40: (event, channel, ...args) 189 - // Older / different shape: (event, payload) 190 - let channel, args; 191 - if (typeof rest[0] === 'string') { 192 - channel = rest[0]; 193 - args = rest.slice(1); 194 - } else if (event && typeof event.channel === 'string') { 195 - channel = event.channel; 196 - args = [event.payload]; 197 - } else { 198 - console.log('[peek:runtime-external:main] ipc-message unrecognized shape; rest=', JSON.stringify(rest).slice(0, 200)); 199 - return; 200 - } 201 - console.log('[peek:runtime-external:main] ipc-message channel=', channel); 202 - if (channel !== SW_CHANNEL_RESPONSE) return; 203 - const [{ reqId, response, error } = {}] = args; 204 - const pending = _pendingByReqId.get(reqId); 205 - if (pending) { 206 - _pendingByReqId.delete(reqId); 207 - pending(error ? { __peekRuntimeExternalError: error } : response); 208 - } else { 209 - console.error('[peek:runtime-external:main] response for unknown reqId=', reqId); 210 - } 211 - }; 212 - if (typeof session.serviceWorkers?.on === 'function') { 213 - session.serviceWorkers.on('ipc-message', onIpcMessage); 214 - } 229 + // SW → main reply path is wired up lazily via swMain.ipc.on inside the 230 + // dispatch path (see _attachedSwIpc above). Electron 40 doesn't fire a 231 + // generic 'ipc-message' event on the ServiceWorkers manager — IPC is 232 + // per-ServiceWorkerMain via its `ipc` IpcMainServiceWorker instance. 215 233 216 234 return { 217 235 cleanup() { 218 236 try { ipcMain.removeHandler(IPC_SEND_FROM_PAGE); } catch { /* idempotent */ } 219 - try { session.serviceWorkers?.off?.('ipc-message', onIpcMessage); } catch { /* idempotent */ } 237 + _attachedSwIpc.clear(); 220 238 _pendingByReqId.clear(); 221 239 }, 222 240 }; ··· 231 249 * onMessageExternal.addListener and handles inbound IPC from main, fanning 232 250 * messages out to registered listeners and bridging sendResponse → main. 233 251 * 234 - * Receive paths tried (Electron-version-resilient): 235 - * - globalThis 'message' events (Electron's serviceWorkers.send arrives 236 - * as a postMessage on the SW global) 237 - * - chrome.runtime.onMessage (in case Electron wraps serviceWorkers.send 238 - * into a runtime message) 239 - * 240 - * Reply path: 241 - * - globalThis.postMessage({ __peekIpcChannel, payload }) — Electron emits 242 - * this as 'ipc-message' on session.serviceWorkers in main. 252 + * Electron 40 wires SW IPC through `electron.ipcRenderer` (exposed in the 253 + * extension SW context as a global `electron` object — same surface used by 254 + * other chrome-api-polyfills like storage-session). swMain.send(channel,...) 255 + * arrives as `ipcRenderer.on(channel, ...)`; replies go back via 256 + * `ipcRenderer.send(channel, ...)` which surfaces on swMain.ipc.on(channel,...). 243 257 * 244 258 * @returns {string} 245 259 */ ··· 254 268 var SW_CHANNEL_RESPONSE = '${SW_CHANNEL_RESPONSE}'; 255 269 console.log('[peek:runtime-external:bg] shim init extId=', chrome.runtime.id); 256 270 271 + // Resolve electron.ipcRenderer. Electron 40's extension SW context may 272 + // expose it through several paths depending on sandbox / contextIsolation 273 + // settings. Try them all. 274 + var _ipc = null; 275 + var _ipcSource = 'none'; 276 + try { 277 + if (typeof electron !== 'undefined' && electron.ipcRenderer) { _ipc = electron.ipcRenderer; _ipcSource = 'global.electron'; } 278 + } catch(_) {} 279 + try { 280 + if (!_ipc && typeof globalThis !== 'undefined' && globalThis.electron && globalThis.electron.ipcRenderer) { _ipc = globalThis.electron.ipcRenderer; _ipcSource = 'globalThis.electron'; } 281 + } catch(_) {} 282 + try { 283 + if (!_ipc && typeof require === 'function') { 284 + var _e = require('electron'); 285 + if (_e && _e.ipcRenderer) { _ipc = _e.ipcRenderer; _ipcSource = "require('electron')"; } 286 + } 287 + } catch(_) {} 288 + try { 289 + if (!_ipc && typeof require === 'function') { 290 + var _er = require('electron/renderer'); 291 + if (_er && _er.ipcRenderer) { _ipc = _er.ipcRenderer; _ipcSource = "require('electron/renderer')"; } 292 + } 293 + } catch(_) {} 294 + console.log('[peek:runtime-external:bg] ipcRenderer source=', _ipcSource, 'available=', !!_ipc, 'electronGlobal=', typeof electron, 'globalThisElectron=', typeof (globalThis||{}).electron, 'require=', typeof require); 295 + if (!_ipc) { 296 + console.error('[peek:runtime-external:bg] electron.ipcRenderer unavailable on all probed paths — cross-origin sendMessage will fail'); 297 + } 298 + 257 299 var _externalListeners = []; 258 300 try { 259 301 var _ome = chrome.runtime.onMessageExternal; 260 302 var _origAdd = _ome && _ome.addListener && _ome.addListener.bind(_ome); 303 + function _wrapListener(fn) { 304 + return function(message, sender, sendResponse) { 305 + try { 306 + var typeStr = message && message.type ? String(message.type) : '(no-type)'; 307 + var senderUrl = sender && (sender.url || sender.origin) || '(no-sender-url)'; 308 + console.log('[peek:runtime-external:bg] LISTENER FIRED via NATIVE dispatch — type=', typeStr, 'sender=', senderUrl); 309 + } catch(_) {} 310 + var ret; 311 + try { ret = fn(message, sender, sendResponse); } 312 + catch(e) { 313 + console.error('[peek:runtime-external:bg] listener threw on native dispatch:', e && e.message); 314 + throw e; 315 + } 316 + if (ret && typeof ret.then === 'function') { 317 + ret.then(function(v) { console.log('[peek:runtime-external:bg] listener (native) resolved'); }, 318 + function(e) { console.error('[peek:runtime-external:bg] listener (native) rejected:', e && e.message); }); 319 + } 320 + return ret; 321 + }; 322 + } 261 323 if (_origAdd) { 262 324 _ome.addListener = function(fn) { 263 - _externalListeners.push(fn); 325 + var wrapped = _wrapListener(fn); 326 + _externalListeners.push(fn); // store the original for our IPC dispatch path 264 327 console.log('[peek:runtime-external:bg] onMessageExternal listener captured (wrap), total=', _externalListeners.length); 265 - try { _origAdd(fn); } catch(_) {} 328 + try { _origAdd(wrapped); } catch(_) {} 266 329 }; 267 330 } else { 268 331 chrome.runtime.onMessageExternal = { ··· 278 341 } catch(e) { console.error('[peek:runtime-external:bg] addListener wrap failed:', e && e.message); } 279 342 280 343 function _sendBack(payload) { 344 + if (!_ipc) { 345 + console.error('[peek:runtime-external:bg] no SW→main IPC path; response dropped reqId=', payload && payload.reqId); 346 + return; 347 + } 281 348 try { 282 - if (typeof globalThis.postMessage === 'function') { 283 - globalThis.postMessage({ __peekIpcChannel: SW_CHANNEL_RESPONSE, payload: payload }); 284 - console.log('[peek:runtime-external:bg] _sendBack via globalThis.postMessage reqId=', payload && payload.reqId); 285 - return; 286 - } 287 - } catch(e) { console.error('[peek:runtime-external:bg] globalThis.postMessage threw:', e && e.message); } 288 - console.error('[peek:runtime-external:bg] no SW→main IPC path; response dropped reqId=', payload && payload.reqId); 349 + _ipc.send(SW_CHANNEL_RESPONSE, payload); 350 + console.log('[peek:runtime-external:bg] _sendBack via ipcRenderer.send reqId=', payload && payload.reqId); 351 + } catch(e) { console.error('[peek:runtime-external:bg] ipcRenderer.send threw:', e && e.message); } 289 352 } 290 353 291 - function _dispatch(envelope, viaPath) { 292 - if (!envelope || envelope.__peekIpcChannel !== SW_CHANNEL_INCOMING) return false; 293 - var payload = envelope.payload || {}; 294 - var reqId = payload.reqId; 295 - var fwdMessage = payload.message; 296 - var fwdSender = payload.sender || {}; 354 + function _dispatch(payload) { 355 + var reqId = payload && payload.reqId; 356 + var fwdMessage = payload && payload.message; 357 + var fwdSender = (payload && payload.sender) || {}; 297 358 fwdSender.id = chrome.runtime.id; 298 - console.log('[peek:runtime-external:bg] dispatch via=', viaPath, 'reqId=', reqId, 'listeners=', _externalListeners.length, 'type=', fwdMessage && fwdMessage.type); 359 + console.log('[peek:runtime-external:bg] dispatch reqId=', reqId, 'listeners=', _externalListeners.length, 'type=', fwdMessage && fwdMessage.type); 299 360 300 361 if (_externalListeners.length === 0) { 301 362 _sendBack({ reqId: reqId, error: 'no onMessageExternal listeners registered' }); 302 - return true; 363 + return; 303 364 } 304 365 var responded = false; 305 366 function respond(response) { ··· 309 370 _sendBack({ reqId: reqId, response: response }); 310 371 } 311 372 for (var i = 0; i < _externalListeners.length; i++) { 312 - try { _externalListeners[i](fwdMessage, fwdSender, respond); } 313 - catch(e) { 373 + try { 374 + var ret = _externalListeners[i](fwdMessage, fwdSender, respond); 375 + // Chrome semantics: returning true OR a Promise keeps the channel 376 + // open for async sendResponse. If it's a Promise, await it. 377 + if (ret && typeof ret.then === 'function') { 378 + (function() { 379 + ret.then(function(r) { respond(r); }, function(e) { 380 + if (!responded) { 381 + responded = true; 382 + _sendBack({ reqId: reqId, error: (e && e.message) || String(e) }); 383 + } 384 + }); 385 + })(); 386 + } 387 + } catch(e) { 314 388 if (!responded) { 315 389 responded = true; 316 390 console.error('[peek:runtime-external:bg] listener THREW reqId=', reqId, 'err=', e && e.message); ··· 318 392 } 319 393 } 320 394 } 321 - return true; 322 395 } 323 396 324 - // Receive path 1: globalThis postMessage 325 - try { 326 - if (typeof globalThis.addEventListener === 'function') { 327 - globalThis.addEventListener('message', function(ev) { _dispatch(ev && ev.data, 'globalThis-message'); }, false); 328 - console.log('[peek:runtime-external:bg] globalThis message listener installed'); 397 + // Listen for incoming dispatches from main. 398 + if (_ipc) { 399 + _ipc.on(SW_CHANNEL_INCOMING, function(_event, payload) { _dispatch(payload); }); 400 + console.log('[peek:runtime-external:bg] ipcRenderer.on(' + SW_CHANNEL_INCOMING + ') installed'); 401 + // Startup heartbeat: announces shim is alive on a debug channel. Main 402 + // listens via swMain.ipc.on('peek:bg-heartbeat') — if it fires, the 403 + // shim is executing and ipcRenderer.send works. If it doesn't, either 404 + // the shim never ran or electron.ipcRenderer is unavailable. 405 + // Send periodically to defeat the startup race: tracer's listener is 406 + // wired up only on 'running-status-changed' → 'running', which fires 407 + // AFTER the SW global has begun executing. A single startup beat 408 + // would always be lost. 409 + var _hbCount = 0; 410 + function _heartbeat() { 411 + try { _ipc.send('peek:bg-heartbeat', { extId: chrome.runtime.id, hasIpcRenderer: true, t: Date.now(), n: ++_hbCount }); } catch(e) {} 329 412 } 330 - } catch(e) { console.error('[peek:runtime-external:bg] globalThis listener install failed:', e && e.message); } 331 - // Receive path 2: chrome.runtime.onMessage 332 - try { 333 - chrome.runtime.onMessage.addListener(function(message) { 334 - _dispatch(message, 'chrome.runtime.onMessage'); 335 - return false; 336 - }); 337 - console.log('[peek:runtime-external:bg] chrome.runtime.onMessage listener installed'); 338 - } catch(e) { console.error('[peek:runtime-external:bg] chrome.runtime.onMessage install failed:', e && e.message); } 413 + _heartbeat(); 414 + setTimeout(_heartbeat, 100); 415 + setTimeout(_heartbeat, 500); 416 + setTimeout(_heartbeat, 1500); 417 + } else { 418 + // No ipcRenderer — try chrome.storage.local as a fallback signal. 419 + try { 420 + if (chrome.storage && chrome.storage.local) { 421 + chrome.storage.local.set({ 422 + __peekRuntimeExternalBgShim: { extId: chrome.runtime.id, t: Date.now(), hasIpcRenderer: false }, 423 + }); 424 + } 425 + } catch(e) {} 426 + } 339 427 })(); 340 428 `; 341 429 }
+9 -1
backend/electron/chrome-extensions.ts
··· 338 338 // Register Chrome API polyfills before loading extensions 339 339 const profileSession = getProfileSession(); 340 340 if (!polyfillHandle) { 341 - polyfillHandle = registerAll(profileSession, { ipcMain, app }); 341 + polyfillHandle = registerAll(profileSession, { 342 + ipcMain, 343 + app, 344 + // For sendMessage(extId, msg) calls from cross-origin pages where the 345 + // requested extId is a hardcoded Web Store ID (Proton's account bundle 346 + // fans out to 4 platform IDs that never match our Electron-assigned 347 + // runtime hash), fall back to resolving by origin. 348 + findExtensionByOrigin: (origin: string) => findExtensionByExternallyConnectableUrl(origin), 349 + }); 342 350 343 351 // Web permission handling now lives in permission-handler.ts (installed 344 352 // by session-partition.ts when the profile session is created). The
+2 -1
backend/electron/entry.ts
··· 270 270 // peek:// URLs anyway, so this check keeps both paths exclusive. 271 271 if (params.src && (params.src.startsWith('http://') || params.src.startsWith('https://'))) { 272 272 const matchedExtId = findExtensionByExternallyConnectableUrl(params.src); 273 - if (matchedExtId) { 273 + if (matchedExtId && process.env.PEEK_DISABLE_RUNTIME_EXTERNAL_POLYFILL !== '1') { 274 274 webPreferences.preload = getRuntimeExternalPreloadPath(); 275 275 webPreferences.additionalArguments = [ 276 276 ...(webPreferences.additionalArguments ?? []), ··· 384 384 let _polyfillScriptRegistered = false; 385 385 const tryRegisterCDPPolyfill = async (url: string) => { 386 386 if (_polyfillScriptRegistered) return; 387 + if (process.env.PEEK_DISABLE_RUNTIME_EXTERNAL_POLYFILL === '1') return; 387 388 if (!url.startsWith('http://') && !url.startsWith('https://')) return; 388 389 const matchedExtId = findExtensionByExternallyConnectableUrl(url); 389 390 if (!matchedExtId) return;
+21 -3
tests/fixtures/desktop-app.ts
··· 89 89 } 90 90 91 91 const tempBase = os.tmpdir(); 92 - const tempDir = path.join(tempBase, `peek-test-${profile}-${Date.now()}`); 92 + // `test-harness-*` profiles are intended for repeat manual runs (e.g. the 93 + // Proton auth harness in tests/manual/). Use a stable path so cookies, 94 + // localStorage, and 2FA-trusted-device state persist across `yarn harness` 95 + // invocations — otherwise the user re-logs every run. 96 + const stable = profile.startsWith('test-harness-'); 97 + const tempDir = stable 98 + ? path.join(tempBase, `peek-test-${profile}`) 99 + : path.join(tempBase, `peek-test-${profile}-${Date.now()}`); 93 100 fs.mkdirSync(tempDir, { recursive: true }); 94 101 profileTempDirs.set(profile, tempDir); 95 102 return tempDir; ··· 121 128 /** 122 129 * Clean up all remaining temp directories 123 130 * Called on process exit to ensure no leftovers 131 + * 132 + * Skips `test-harness-*` profiles — those are intentionally persistent so 133 + * the user doesn't have to re-log in / re-trust the device on every run. 124 134 */ 125 135 function cleanupAllTempDirs(): void { 126 136 for (const [profile, tempDir] of profileTempDirs.entries()) { 137 + if (profile.startsWith('test-harness-')) continue; 127 138 removeTempDir(tempDir); 128 139 } 129 140 profileTempDirs.clear(); ··· 685 696 */ 686 697 export const test = base.extend<{ desktopApp: DesktopApp }>({ 687 698 desktopApp: async ({}, use, testInfo) => { 688 - // Use test title as profile base for isolation 689 - const profile = `test-${testInfo.title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}`; 699 + // Manual harness specs (project=manual, e.g. tests/manual/*.harness.ts) 700 + // use a stable `test-harness-<spec>` profile so cookies, localStorage, 701 + // and 2FA-trusted-device state persist across runs — otherwise the user 702 + // re-logs every invocation. Regular Playwright tests get a fresh 703 + // Date.now()-suffixed profile per run for isolation. 704 + const isHarness = testInfo.project.name === 'manual'; 705 + const profile = isHarness 706 + ? `test-harness-${path.basename(testInfo.file).replace(/\.harness\.ts$/, '').replace(/\s+/g, '-').toLowerCase()}` 707 + : `test-${testInfo.title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}`; 690 708 const app = await launchDesktopApp(profile); 691 709 await use(app); 692 710 await app.close();
+161 -22
tests/manual/harness/tracer.ts
··· 83 83 } 84 84 85 85 // Main-process tap: attaches CDP debugger to all current + future webContents 86 - // and buffers events on a globalThis array. Writing to fs here is not 87 - // possible — `electronApp.evaluate(fn)` serializes `fn` to source and evals 88 - // it in the main process, where neither `require` (ESM main) nor dynamic 89 - // `import()` (no import callback registered for eval'd code) work. So we 90 - // buffer in-process and the test side drains via a follow-up evaluateMain. 91 - await opts.app.evaluateMain(async ({ app, webContents }) => { 86 + // and buffers events on a globalThis array. We can't import fs here 87 + // (evaluateMain has no import callback registered), so we periodically 88 + // drain via evaluateMain from the test side — see startPeriodicDrain below. 89 + await opts.app.evaluateMain(async ({ app, webContents, session }: any, args: any) => { 92 90 const g: any = globalThis as any; 93 91 if (g.__peekHarnessBuffer) return; // already attached this run 94 92 const buf: any[] = []; 95 93 g.__peekHarnessBuffer = buf; 94 + const pushAndStream = (obj: any) => buf.push(obj); 95 + 96 + // Tap main-process stdout/stderr — captures console.log/error from 97 + // backend/electron/* including our [peek:runtime-external:main] logs, 98 + // which tell us which BG-dispatch error path is being hit. 99 + const _origStdoutWrite = process.stdout.write.bind(process.stdout); 100 + const _origStderrWrite = process.stderr.write.bind(process.stderr); 101 + (process.stdout as any).write = (chunk: any, ...rest: any[]) => { 102 + try { 103 + const s = typeof chunk === 'string' ? chunk : chunk?.toString?.() ?? String(chunk); 104 + pushAndStream({ t: Date.now(), wcId: -1, kind: 'main-stdout', text: s }); 105 + } catch {} 106 + return _origStdoutWrite(chunk, ...rest); 107 + }; 108 + (process.stderr as any).write = (chunk: any, ...rest: any[]) => { 109 + try { 110 + const s = typeof chunk === 'string' ? chunk : chunk?.toString?.() ?? String(chunk); 111 + pushAndStream({ t: Date.now(), wcId: -1, kind: 'main-stderr', text: s }); 112 + } catch {} 113 + return _origStderrWrite(chunk, ...rest); 114 + }; 96 115 97 116 const pendingStatuses = new Map<number, Map<string, number>>(); 98 117 function statusMap(wcId: number): Map<string, number> { ··· 108 127 try { 109 128 if (!wc.debugger.isAttached()) wc.debugger.attach('1.3'); 110 129 } catch (e: any) { 111 - buf.push({ t: Date.now(), wcId: wc.id, kind: 'attach-error', error: String(e?.message || e) }); 130 + pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'attach-error', error: String(e?.message || e) }); 112 131 return; 113 132 } 114 133 const info = { id: wc.id, type: wc.getType(), url: wc.getURL() }; 115 - buf.push({ t: Date.now(), wcId: wc.id, kind: 'attached', info }); 134 + pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'attached', info }); 116 135 117 136 wc.debugger.sendCommand('Network.enable').catch(() => {}); 118 137 wc.debugger.sendCommand('Page.enable').catch(() => {}); ··· 120 139 wc.debugger.sendCommand('Log.enable').catch(() => {}); 121 140 122 141 wc.debugger.on('message', async (_event: any, method: string, params: any) => { 123 - buf.push({ t: Date.now(), wcId: wc.id, kind: 'cdp', method, params }); 142 + pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'cdp', method, params }); 124 143 if (method === 'Network.responseReceived') { 125 144 if (params?.response?.status != null) statusMap(wc.id).set(params.requestId, params.response.status); 126 145 } ··· 130 149 if (status != null && status >= 400) { 131 150 try { 132 151 const body = await wc.debugger.sendCommand('Network.getResponseBody', { requestId: reqId }); 133 - buf.push({ t: Date.now(), wcId: wc.id, kind: 'response-body', requestId: reqId, body: body.body, base64Encoded: body.base64Encoded }); 152 + pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'response-body', requestId: reqId, body: body.body, base64Encoded: body.base64Encoded }); 134 153 } catch (e: any) { 135 - buf.push({ t: Date.now(), wcId: wc.id, kind: 'response-body-error', requestId: reqId, error: String(e?.message || e) }); 154 + pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'response-body-error', requestId: reqId, error: String(e?.message || e) }); 136 155 } 137 156 } 138 157 statusMap(wc.id).delete(reqId); 139 158 } 140 159 }); 141 160 142 - wc.on('did-navigate', (_e: any, url: string) => buf.push({ t: Date.now(), wcId: wc.id, kind: 'did-navigate', url })); 143 - wc.on('did-navigate-in-page', (_e: any, url: string) => buf.push({ t: Date.now(), wcId: wc.id, kind: 'did-navigate-in-page', url })); 144 - wc.on('destroyed', () => buf.push({ t: Date.now(), wcId: wc.id, kind: 'destroyed' })); 161 + wc.on('did-navigate', (_e: any, url: string) => pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'did-navigate', url })); 162 + wc.on('did-navigate-in-page', (_e: any, url: string) => pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'did-navigate-in-page', url })); 163 + wc.on('destroyed', () => pushAndStream({ t: Date.now(), wcId: wc.id, kind: 'destroyed' })); 145 164 } 146 165 147 166 webContents.getAllWebContents().forEach(attach); 148 167 app.on('web-contents-created', (_e: any, wc: any) => attach(wc)); 149 - }); 168 + 169 + // Service worker console capture — SW console output doesn't go through 170 + // process.stdout. Peek loads extensions into a per-profile partition 171 + // session (`persist:<profile>`) — `session.defaultSession` doesn't see 172 + // them. Hook the profile partition explicitly. 173 + const sessionsToHook = [session.defaultSession]; 174 + if (args?.profile) { 175 + try { sessionsToHook.push(session.fromPartition(`persist:${args.profile}`)); } catch {} 176 + } 177 + const seenSessions = new Set<any>(); 178 + function attachSwConsole(s: any, label: string) { 179 + if (!s || seenSessions.has(s)) return; 180 + seenSessions.add(s); 181 + pushAndStream({ t: Date.now(), wcId: -2, kind: 'sw-session-attached', label }); 182 + try { 183 + s.serviceWorkers?.on?.('console-message', (_e: any, details: any) => { 184 + pushAndStream({ 185 + t: Date.now(), wcId: -2, kind: 'sw-console', 186 + level: details?.level, message: details?.message, 187 + sourceUrl: details?.sourceUrl, lineNumber: details?.lineNumber, 188 + versionId: details?.versionId, sessionLabel: label, 189 + }); 190 + }); 191 + s.serviceWorkers?.on?.('registration-completed', (_e: any, details: any) => { 192 + pushAndStream({ 193 + t: Date.now(), wcId: -2, kind: 'sw-registration', 194 + label, scope: details?.scope, scriptUrl: details?.scriptUrl, 195 + }); 196 + }); 197 + s.serviceWorkers?.on?.('running-status-changed', (details: any) => { 198 + pushAndStream({ 199 + t: Date.now(), wcId: -2, kind: 'sw-status', 200 + label, versionId: details?.versionId, runningStatus: details?.runningStatus, 201 + }); 202 + // When an SW comes online, attach its swMain.ipc listeners so we 203 + // can capture debug heartbeats from the BG runtime-external shim. 204 + if (details?.runningStatus === 'running' && details?.versionId != null) { 205 + try { 206 + const swMain = s.serviceWorkers?.getWorkerFromVersionID?.(details.versionId); 207 + if (swMain && swMain.ipc?.on) { 208 + swMain.ipc.on('peek:bg-heartbeat', (_e: any, payload: any) => { 209 + pushAndStream({ 210 + t: Date.now(), wcId: -2, kind: 'sw-heartbeat', 211 + label, versionId: details.versionId, payload, 212 + }); 213 + }); 214 + } 215 + } catch {} 216 + } 217 + }); 218 + } catch (e: any) { 219 + pushAndStream({ t: Date.now(), wcId: -2, kind: 'sw-session-attach-error', label, error: String(e?.message || e) }); 220 + } 221 + } 222 + sessionsToHook.forEach((s, i) => attachSwConsole(s, i === 0 ? 'default' : `partition:${args?.profile}`)); 223 + app.on('session-created', (s: any) => attachSwConsole(s, 'session-created')); 224 + }, { profile: opts.profile }); 225 + 226 + // Periodic drain: every 500ms, pull whatever's in __peekHarnessBuffer and 227 + // append to the JSONL on disk. Survives app shutdown — most we lose is 228 + // ~500ms of trailing events. Stopped in dump(). 229 + const drainTimer: NodeJS.Timeout | null = setInterval(async () => { 230 + try { 231 + const drained = await opts.app.evaluateMain!(() => { 232 + const g: any = globalThis as any; 233 + const out = (g.__peekHarnessBuffer || []).slice(); 234 + if (g.__peekHarnessBuffer) g.__peekHarnessBuffer.length = 0; 235 + return out; 236 + }) as RawCdpLine[]; 237 + if (drained.length === 0) return; 238 + const lines = drained.map((e) => JSON.stringify(e)).join('\n') + '\n'; 239 + try { fs.appendFileSync(jsonlPath, lines); } catch {} 240 + } catch { /* app may be shutting down — that's fine, we have what we have */ } 241 + }, 500); 150 242 151 243 // ---- Tracer object ---- 152 244 const tracer: Tracer = { ··· 203 295 const endedAt = Date.now(); 204 296 const endedAtIso = new Date(endedAt).toISOString(); 205 297 206 - // Drain the main-process buffer and detach debuggers in one trip. 207 - let mainEvents: RawCdpLine[] = []; 298 + // Stop the periodic drain. 299 + if (drainTimer) clearInterval(drainTimer); 300 + 301 + // Final flush: pull any straggling events from main-process buffer. 302 + // Best-effort — if the app is shutting down, we already have most of 303 + // the data via the periodic drain. 208 304 try { 209 - mainEvents = await opts.app.evaluateMain!(({ webContents }) => { 305 + const drained = await opts.app.evaluateMain!(({ webContents }: any) => { 210 306 const g: any = globalThis as any; 211 - const drained = (g.__peekHarnessBuffer || []).slice(); 307 + const out = (g.__peekHarnessBuffer || []).slice(); 212 308 if (g.__peekHarnessBuffer) g.__peekHarnessBuffer.length = 0; 213 309 for (const wc of webContents.getAllWebContents()) { 214 310 try { if (!wc.isDestroyed() && wc.debugger.isAttached()) wc.debugger.detach(); } catch {} 215 311 } 216 - return drained; 312 + return out; 217 313 }) as RawCdpLine[]; 314 + if (drained.length > 0) { 315 + const lines = drained.map((e) => JSON.stringify(e)).join('\n') + '\n'; 316 + try { fs.appendFileSync(jsonlPath, lines); } catch {} 317 + } 218 318 } catch { /* app may already be closing */ } 219 319 220 - // Persist the raw drain as a forensic record. 320 + // Source of truth: the streamed JSONL file. Survives even if the dump's 321 + // evaluateMain failed (e.g. app shutting down after auth window close). 322 + let mainEvents: RawCdpLine[] = []; 221 323 try { 222 - fs.writeFileSync(jsonlPath, mainEvents.map((e) => JSON.stringify(e)).join('\n') + (mainEvents.length ? '\n' : '')); 324 + const raw = fs.readFileSync(jsonlPath, 'utf-8'); 325 + for (const line of raw.split('\n')) { 326 + if (!line.trim()) continue; 327 + try { mainEvents.push(JSON.parse(line)); } catch {} 328 + } 223 329 } catch {} 224 330 225 331 // Build wcId → label map from "attached" events; subsequent events use it. ··· 283 389 } 284 390 if (evt.kind === 'response-body-error') { 285 391 return [{ t, kind: 'note', source, data: { msg: 'response-body-error', requestId: evt.requestId, error: evt.error } }]; 392 + } 393 + if (evt.kind === 'main-stdout' || evt.kind === 'main-stderr') { 394 + const text = (evt as any).text as string; 395 + const lines = text.split('\n').filter((l) => l.length > 0); 396 + return lines.map((line) => ({ 397 + t, kind: 'console', source: evt.kind === 'main-stderr' ? 'main:stderr' : 'main:stdout', 398 + data: { level: evt.kind === 'main-stderr' ? 'error' : 'log', text: line }, 399 + })); 400 + } 401 + if (evt.kind === 'sw-console') { 402 + const e = evt as any; 403 + return [{ 404 + t, kind: 'console', source: `sw:vid=${e.versionId ?? '?'}@${e.sessionLabel ?? '?'}`, 405 + data: { level: e.level, text: e.message, sourceUrl: e.sourceUrl, lineNumber: e.lineNumber }, 406 + }]; 407 + } 408 + if (evt.kind === 'sw-session-attached') { 409 + return [{ t, kind: 'note', source: `sw:${(evt as any).label}`, data: { msg: 'sw-session-attached' } }]; 410 + } 411 + if (evt.kind === 'sw-session-attach-error') { 412 + return [{ t, kind: 'note', source: `sw:${(evt as any).label}`, data: { msg: 'sw-session-attach-error', error: (evt as any).error } }]; 413 + } 414 + if (evt.kind === 'sw-registration') { 415 + const e = evt as any; 416 + return [{ t, kind: 'note', source: `sw:${e.label}`, data: { msg: 'sw-registration', scope: e.scope, scriptUrl: e.scriptUrl } }]; 417 + } 418 + if (evt.kind === 'sw-status') { 419 + const e = evt as any; 420 + return [{ t, kind: 'note', source: `sw:${e.label}`, data: { msg: 'sw-status', versionId: e.versionId, runningStatus: e.runningStatus } }]; 421 + } 422 + if (evt.kind === 'sw-heartbeat') { 423 + const e = evt as any; 424 + return [{ t, kind: 'note', source: `sw:vid=${e.versionId}@${e.label}`, data: { msg: 'sw-heartbeat', payload: e.payload } }]; 286 425 } 287 426 if (evt.kind !== 'cdp' || !evt.method) return null; 288 427