experiments in a post-browser web
10
fork

Configure Feed

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

fix(chrome-ext): unbreak Proton Pass autofill — install browser.permissions on SW + lock chrome

Autofill (form-field icons, dropdown UI) never appeared in webview guests
because the Proton Pass background service worker aborted at sync init.

Root cause: Electron's native `browser` polyfill in service-worker contexts
exposes only the APIs Electron implements natively (action, alarms,
extension, i18n, management, offscreen, runtime, scripting, storage, tabs,
webRequest) — `permissions` is not in that list. Proton's BG calls
`browser.permissions.onAdded.addListener(...)` synchronously during init,
throws TypeError, and aborts. With BG aborted, no chrome.runtime.onMessage
handler is registered, so the orchestrator content script's
LOAD_CONTENT_SCRIPT message goes unanswered, client.js never gets injected,
and the autofill UI never appears.

Compounding: Proton's bundled "extension API isolation" feature schedules a
`setTimeout(0)` that replaces `globalThis.chrome` with a Proxy that returns
errors for every property except 'app'. In page contexts, content scripts
disable this via the orchestrator's call to `uE()`. In SW contexts, nothing
calls `uE()`, so the wrap fires and would clobber chrome.* access for the
rest of the SW lifetime — *if* anything reached the addListener line.

Fix in scripts/patch-chrome-extensions.js's PERMISSIONS_SHIM:
- Install `globalThis.browser.permissions = _shimPerms` (locked
non-writable / non-configurable) immediately after the chrome wrap. This
satisfies Proton's webextension-polyfill consumer and unblocks BG init.
- Lock `globalThis.chrome` itself with writable:false / configurable:false
so Proton's setTimeout-based Proxy-wrap can't clobber the binding. The
follow-on assignment fails with "Cannot assign to read only property" —
benign, since no critical addListener calls remain after that point.

Also adds an opt-in diagnostic shim (PEEK_PROTON_DIAG=1 at patch time)
that wraps chrome.runtime.onMessage and chrome.scripting.executeScript
with loggers writing to chrome.storage.local under '__peekProtonDiag'.
Off by default; useful for future webview-extension debugging.

Adds `tests/desktop/proton-pass-autofill.spec.ts`: opens a local login
form in a Peek page tile, waits up to 10s for any data-protonpass-* /
proton-class / chrome-extension iframe to appear in the webview DOM,
asserts at least one such marker exists.

+256 -15
+142 -15
scripts/patch-chrome-extensions.js
··· 38 38 * Chrome callback convention). Without this, the 39 39 * service worker crashes at startup on permissions.onAdded.addListener(). 40 40 */ 41 - const PERMISSIONS_SHIM = `/* Peek: chrome.permissions shim for Electron compatibility. 41 + const PERMISSIONS_SHIM = `/* Peek: chrome.permissions shim for Electron compatibility (background SW). 42 42 * Strategy: replace globalThis.chrome with a Proxy whose target is a plain 43 43 * empty object {}. The Proxy's get trap returns our shim for 'permissions' 44 - * and delegates everything else to the real native chrome. 44 + * and delegates everything else to the real native chrome. After installing, 45 + * LOCK globalThis.chrome with writable:false / configurable:false so 46 + * Proton's bundled "extension API isolation" wrapper (a setTimeout(0) that 47 + * replaces globalThis.chrome with a Proxy returning errors for every prop 48 + * except 'app') CANNOT clobber us — that wrap would otherwise break 49 + * webextension-polyfill's lazy browser.permissions, halting BG init at 50 + * 'a8.permissions.onAdded.addListener' and so blocking autofill entirely. 45 51 * 46 - * Why this shape: 52 + * Why the empty-target shape: 47 53 * 1. Electron's native chrome.permissions is a non-configurable, non-writable 48 54 * property on the native chrome object. We can't redefine it via 49 55 * defineProperty, and even mutating its methods doesn't help once a 50 - * downstream consumer (e.g. Proton Pass) wraps chrome in their own Proxy 51 - * that hides 'permissions' — the V8 invariant fires because the target 52 - * property is non-configurable but the consumer's get trap returns 53 - * undefined. 56 + * downstream consumer wraps chrome in their own Proxy that hides 57 + * 'permissions' — the V8 invariant fires because the target property is 58 + * non-configurable but the consumer's get trap returns undefined. 54 59 * 2. Replacing chrome with a Proxy whose target is {} means the target has 55 - * NO own properties — no invariants apply to anything. Downstream Proxies 56 - * can wrap us freely without tripping V8's checks. 57 - * 3. The replacement uses configurable:true / writable:true so other code 58 - * (Proton's polyfill) can still wrap us in their own Proxy. */ 60 + * NO own properties — no invariants apply to anything. */ 59 61 (function() { 60 62 var DEBUG_PERMISSIONS = false; 61 63 function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions]'].concat(Array.prototype.slice.call(arguments))); } ··· 144 146 try { 145 147 Object.defineProperty(globalThis, 'chrome', { 146 148 value: wrapWithPermissionsShim(_chrome, _shimPerms), 147 - writable: true, configurable: true, enumerable: true, 149 + writable: false, configurable: false, enumerable: true, 148 150 }); 149 - _log('chrome wrapped with permissions Proxy'); 151 + _log('chrome wrapped with permissions Proxy (locked)'); 152 + // Electron's native browser polyfill (in SW context) omits 'permissions' 153 + // — it only exposes APIs Electron implements natively. Proton's BG calls 154 + // browser.permissions.onAdded.addListener at sync init, throwing and 155 + // aborting BG before any chrome.runtime.onMessage handler is registered. 156 + // Plug in our shim on browser.permissions too. 157 + try { 158 + if (typeof globalThis.browser === 'object' && globalThis.browser && !globalThis.browser.permissions) { 159 + Object.defineProperty(globalThis.browser, 'permissions', { 160 + value: _shimPerms, writable: false, configurable: false, enumerable: true, 161 + }); 162 + _log('browser.permissions installed'); 163 + } 164 + } catch(e) { _log('browser.permissions install failed:', e.message); } 150 165 } catch(e) { _log('chrome wrap failed:', e.message); } 151 166 })(); 152 167 `; ··· 352 367 })(); 353 368 `; 354 369 370 + /** 371 + * Optional diagnostic shim — only injected when env var 372 + * PEEK_PROTON_DIAG=1 is set at patch time. Wraps chrome.runtime.onMessage, 373 + * chrome.runtime.sendMessage, and chrome.scripting.executeScript with 374 + * lightweight loggers that stash entries in chrome.storage.local under 375 + * '__peekProtonDiag'. Read from any extension-context page (popup, 376 + * settings, etc.) via chrome.storage.local.get(['__peekProtonDiag']). 377 + */ 378 + const SCRIPTING_DIAG_SHIM = `// --- Peek diagnostic shim (PROTON_DIAG) --- 379 + (function() { 380 + if (typeof chrome === 'undefined') return; 381 + var _log = []; 382 + var _flushScheduled = false; 383 + function _push(entry) { 384 + entry.t = Date.now(); 385 + _log.push(entry); 386 + globalThis._peekProtonDiag = _log; 387 + if (!_flushScheduled && chrome.storage && chrome.storage.local) { 388 + _flushScheduled = true; 389 + Promise.resolve().then(function() { 390 + _flushScheduled = false; 391 + try { chrome.storage.local.set({ __peekProtonDiag: JSON.stringify(_log) }); } catch(e) {} 392 + }); 393 + } 394 + } 395 + _push({ kind: 'shim_installed' }); 396 + 397 + // Capture top-level errors during BG init so we see if Proton's bundle 398 + // throws before reaching chrome.runtime.onMessage.addListener. 399 + try { 400 + self.addEventListener('error', function(ev) { 401 + _push({ kind: 'sw_error', message: ev.message || String(ev), filename: ev.filename, lineno: ev.lineno, colno: ev.colno }); 402 + }); 403 + self.addEventListener('unhandledrejection', function(ev) { 404 + _push({ kind: 'sw_unhandled_rejection', reason: String(ev.reason && ev.reason.message || ev.reason) }); 405 + }); 406 + _push({ kind: 'error_handlers_installed' }); 407 + } catch(e) { _push({ kind: 'error_handler_install_failed', err: String(e && e.message || e) }); } 408 + 409 + // Wrap chrome.runtime.onMessage.addListener — log every dispatch. 410 + try { 411 + var _origAdd = chrome.runtime.onMessage.addListener.bind(chrome.runtime.onMessage); 412 + chrome.runtime.onMessage.addListener = function(fn) { 413 + _push({ kind: 'addListener_called', fnName: fn && fn.name }); 414 + var wrapped = function(message, sender, sendResponse) { 415 + var msgType = message && message.type ? String(message.type) : '(no-type)'; 416 + _push({ kind: 'onMessage', msgType: msgType, sender: { url: sender && sender.url, tabId: sender && sender.tab && sender.tab.id, frameId: sender && sender.frameId } }); 417 + try { 418 + return fn(message, sender, sendResponse); 419 + } catch(e) { 420 + _push({ kind: 'onMessage_handler_threw', msgType: msgType, err: String(e && e.message || e) }); 421 + throw e; 422 + } 423 + }; 424 + return _origAdd(wrapped); 425 + }; 426 + } catch(e) { _push({ kind: 'addListener_wrap_failed', err: String(e && e.message || e) }); } 427 + 428 + // Probe chrome state at various tick boundaries so we can see when (if ever) 429 + // Proton's allowProxy wrap fires. 430 + function _probe(label) { 431 + try { 432 + var browserKeys = []; 433 + try { if (globalThis.browser) browserKeys = Object.keys(globalThis.browser).slice(0, 30); } catch {} 434 + var info = { 435 + kind: 'probe', label: label, 436 + typeofChrome: typeof chrome, 437 + typeofRuntime: typeof chrome.runtime, 438 + chromePermissions: typeof chrome.permissions, 439 + chromePermsOnAdded: typeof chrome.permissions?.onAdded, 440 + typeofBrowser: typeof globalThis.browser, 441 + browserKeys: browserKeys, 442 + browserPermissions: typeof globalThis.browser?.permissions, 443 + browserPermsOnAdded: typeof globalThis.browser?.permissions?.onAdded, 444 + }; 445 + _push(info); 446 + } catch (e) { 447 + _push({ kind: 'probe_threw', label: label, err: String(e && e.message || e) }); 448 + } 449 + } 450 + _probe('sync_after_install'); 451 + Promise.resolve().then(function() { _probe('microtask_after_install'); }); 452 + setTimeout(function() { _probe('timeout0_after_install'); }, 0); 453 + setTimeout(function() { _probe('timeout500_after_install'); }, 500); 454 + 455 + // Wrap chrome.scripting.executeScript — log every call + result. 456 + try { 457 + var _origExec = chrome.scripting && chrome.scripting.executeScript; 458 + if (_origExec) { 459 + chrome.scripting.executeScript = function(opts) { 460 + var summary = { kind: 'executeScript', target: opts && opts.target, files: opts && opts.files, hasFunc: !!(opts && opts.func) }; 461 + _push(summary); 462 + try { 463 + var p = _origExec.call(chrome.scripting, opts); 464 + if (p && typeof p.then === 'function') { 465 + return p.then(function(r) { _push({ kind: 'executeScript_result', target: opts && opts.target, files: opts && opts.files, ok: true }); return r; }) 466 + .catch(function(e) { _push({ kind: 'executeScript_result', target: opts && opts.target, files: opts && opts.files, ok: false, err: String(e && e.message || e) }); throw e; }); 467 + } 468 + return p; 469 + } catch(e) { 470 + _push({ kind: 'executeScript_threw', err: String(e && e.message || e) }); 471 + throw e; 472 + } 473 + }; 474 + } else { 475 + _push({ kind: 'no_chrome_scripting' }); 476 + } 477 + } catch(e) { _push({ kind: 'executeScript_wrap_failed', err: String(e && e.message || e) }); } 478 + })(); 479 + `; 480 + 355 481 function patchProtonPass() { 356 482 const extPath = path.join(EXT_DIR, 'proton-pass'); 357 483 ··· 379 505 // Insert after permissions shim closing })(); 380 506 const marker = '})();\n'; 381 507 const idx = content.indexOf(marker); 508 + const diag = process.env.PEEK_PROTON_DIAG === '1' ? SCRIPTING_DIAG_SHIM : ''; 382 509 if (idx !== -1) { 383 510 const insertAt = idx + marker.length; 384 - content = content.slice(0, insertAt) + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM + content.slice(insertAt); 511 + content = content.slice(0, insertAt) + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM + diag + content.slice(insertAt); 385 512 } else { 386 - content = content + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM; 513 + content = content + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM + diag; 387 514 } 388 515 patched = true; 389 516 }
+114
tests/desktop/proton-pass-autofill.spec.ts
··· 1 + /** 2 + * Regression spec for Proton Pass autofill in webview guests. 3 + * 4 + * Bug: Proton Pass autofill UI never appeared in form fields. Root cause: 5 + * Electron's native `browser` polyfill in service-worker contexts omits 6 + * `permissions` (only exposes APIs Electron implements natively). Proton's 7 + * BG SW calls `browser.permissions.onAdded.addListener` at sync init, 8 + * throws TypeError, and aborts BG before any chrome.runtime.onMessage 9 + * handler is registered — so the popup-content-script 10 + * → SW → chrome.scripting.executeScript('client.js') chain that injects the 11 + * autofill UI never starts. 12 + * 13 + * Fix: scripts/patch-chrome-extensions.js's PERMISSIONS_SHIM now also 14 + * installs `globalThis.browser.permissions = _shimPerms` (locked 15 + * non-writable) when patching background.js, so Proton's 16 + * webextension-polyfill consumer finds permissions on browser. 17 + * 18 + * This spec asserts that after navigating a webview to a login form, 19 + * Proton's content scripts inject DOM nodes (data-protonpass-* markers 20 + * or the dropdown iframe). 21 + */ 22 + 23 + import { test, expect } from '../fixtures/desktop-app'; 24 + import { Page } from '@playwright/test'; 25 + import { createPerDescribeApp } from '../helpers/test-app'; 26 + import { waitForExtensionsReady } from '../helpers/window-utils'; 27 + import http from 'http'; 28 + 29 + test.describe('Proton Pass autofill in webview @desktop', () => { 30 + let app: any; 31 + let bgWindow: Page; 32 + let server: http.Server; 33 + let port: number; 34 + 35 + test.beforeAll(async () => { 36 + ({ app, bgWindow } = await createPerDescribeApp('proton-pass-autofill')); 37 + await waitForExtensionsReady(bgWindow, 15000); 38 + 39 + await new Promise<void>((resolve) => { 40 + server = http.createServer((req, res) => { 41 + if (req.url === '/login') { 42 + res.writeHead(200, { 'Content-Type': 'text/html' }); 43 + res.end(`<!DOCTYPE html> 44 + <html><head><title>Login</title></head> 45 + <body> 46 + <h1 id="title">Login</h1> 47 + <form id="loginForm" action="/submit" method="post"> 48 + <input id="username" name="username" type="text" autocomplete="username" /> 49 + <input id="password" name="password" type="password" autocomplete="current-password" /> 50 + <button id="submit" type="submit">Sign in</button> 51 + </form> 52 + </body></html>`); 53 + } else { 54 + res.writeHead(404); 55 + res.end('Not found'); 56 + } 57 + }); 58 + server.listen(0, '127.0.0.1', () => { 59 + const addr = server.address(); 60 + port = typeof addr === 'object' && addr ? addr.port : 0; 61 + resolve(); 62 + }); 63 + }); 64 + }); 65 + 66 + test.afterAll(async () => { 67 + if (server) await new Promise<void>((r) => server.close(() => r())); 68 + if (app) await app.close(); 69 + }); 70 + 71 + test('Proton Pass injects autofill UI into form fields', async () => { 72 + const url = `http://127.0.0.1:${port}/login`; 73 + 74 + const openResult = await bgWindow.evaluate(async (targetUrl: string) => { 75 + return await (window as any).app.window.open(targetUrl, { width: 800, height: 600 }); 76 + }, url); 77 + expect(openResult.success).toBe(true); 78 + 79 + const pageWindow = await app.getWindow('page/index.html', 15000); 80 + expect(pageWindow).toBeTruthy(); 81 + 82 + await pageWindow.waitForFunction( 83 + () => { 84 + const wv = document.getElementById('content'); 85 + return wv && wv.classList.contains('loaded'); 86 + }, 87 + undefined, 88 + { timeout: 20000 }, 89 + ); 90 + 91 + // Poll up to 10s for Proton's autofill chain to inject DOM markers. 92 + // Manual loop avoids waitForFunction's quirky handling of async predicates 93 + // that depend on webview.executeJavaScript(). 94 + let result = { protonNodeCount: 0, protonIframeCount: 0 }; 95 + const deadline = Date.now() + 10000; 96 + while (Date.now() < deadline) { 97 + result = await pageWindow.evaluate(async () => { 98 + const wv = document.getElementById('content') as any; 99 + const protonNodeCount = await wv.executeJavaScript(` 100 + document.querySelectorAll('[class*="proton"],[id*="proton"],[data-protonpass-role],[data-protonpass-base-css]').length 101 + `); 102 + const protonIframeCount = await wv.executeJavaScript(` 103 + document.querySelectorAll('iframe[src*="chrome-extension://"]').length 104 + `); 105 + return { protonNodeCount, protonIframeCount }; 106 + }); 107 + if (result.protonNodeCount + result.protonIframeCount > 0) break; 108 + await pageWindow.waitForTimeout(250); 109 + } 110 + 111 + console.log('[autofill] final injection counts:', JSON.stringify(result)); 112 + expect(result.protonNodeCount + result.protonIframeCount).toBeGreaterThan(0); 113 + }); 114 + });