experiments in a post-browser web
10
fork

Configure Feed

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

refactor(chrome-ext): keep popup-side fixes, drop runtime-bridge ext patches

Sets up the foundation for the native chrome.runtime.sendMessage
cross-origin polyfill in the next commit. Two threads of work landed
together:

1. Late-night debugging of Proton Pass auth-fork added diagnostic
logging + several patch-the-extension hacks (RUNTIME_BRIDGE_*,
peek-runtime-polyfill.js content_script, orchestrator.js append).
The extension-internals approach mechanically worked but interacted
poorly with Electron's webview sandbox bootstrap (SIGSEGV on /reauth)
and required modifying every extension we wanted to support. All of
that hack-layer is reverted here.

2. New fixes that are general-purpose and worth keeping:
- chrome-extensions.ts: setWindowOpenHandler on extension popup
BrowserWindows that routes window.open() through invokeWindowOpen,
so Proton's "open auth URL externally" path opens a Peek page tile
instead of failing silently.
- chrome-extensions.ts: console-message forwarding from extension
popups to main stderr in DEBUG mode (visibility for popup-side JS
errors that would otherwise be swallowed by Sentry handlers).
- entry.ts: webview-guest console-message forwarding + DevTools
auto-open for proton.me URLs in DEBUG mode (general extension
diagnostics).
- patch-chrome-extensions.js peek-permissions.js (popup-side):
chrome.tabs.create polyfill + window.error / unhandledrejection
trap. Electron's native chrome.tabs in extension-popup
BrowserWindow contexts exposes query() but not create(); without
the polyfill, Proton's login click throws "rR.tabs.create is not
a function" and the popup self-closes.
- patch-chrome-extensions.js PERMISSIONS_SHIM (BG-side):
globalThis.chrome accessor-lock fix — use getter/no-op-setter
instead of writable:false so Proton's bundled "extension API
isolation" forEach (which assigns globalThis.chrome = proxy)
doesn't throw mid-loop and abort BG init.

Net effect on chrome-extensions/proton-pass/: orchestrator.js stays
un-modified, manifest.json has no Peek content_script entries, no
peek-runtime-polyfill.js file ships. The patcher is now scoped to
background.js + popup HTML files (peek-permissions.js script tag).

The cross-origin chrome.runtime.sendMessage → onMessageExternal
pathway lands as a proper chrome-api-polyfill module in the next
commit.

+154 -12
+46
backend/electron/chrome-extensions.ts
··· 19 19 import { getProfileSession } from './session-partition.js'; 20 20 import { registerAll } from './chrome-api-polyfills/index.js'; 21 21 import { buildOptionsCommandName, computeUiEntries } from './chrome-extensions-helpers.js'; 22 + import { invokeWindowOpen } from './ipc.js'; 22 23 23 24 const DEBUG = !!process.env.DEBUG; 24 25 ··· 556 557 win.on('closed', () => { 557 558 extensionUiWindows.delete(windowKey); 558 559 DEBUG && console.log(`[chrome-ext] UI window closed: ${windowKey}`); 560 + }); 561 + 562 + if (DEBUG) { 563 + // Forward renderer console + JS errors to main stderr so popup-side 564 + // failures (which would otherwise live only in the popup's DevTools) 565 + // surface in the same log stream as the rest of the extension wiring. 566 + // NOTE: console-message does NOT fire when DevTools is attached, so we 567 + // do not auto-open DevTools here. If you need to inspect manually, 568 + // right-click → Inspect inside the popup. 569 + win.webContents.on('console-message', (event) => { 570 + try { 571 + const e = event as unknown as { level?: unknown; message?: unknown; lineNumber?: unknown; sourceId?: unknown }; 572 + console.log(`[ext-popup:${windowKey}:${String(e.level)}] ${String(e.message)} (${String(e.sourceId)}:${String(e.lineNumber)})`); 573 + } catch { 574 + /* logging must never throw */ 575 + } 576 + }); 577 + win.webContents.on('render-process-gone', (_e, details) => { 578 + console.error(`[ext-popup:${windowKey}] render-process-gone:`, details); 579 + }); 580 + } 581 + 582 + // Route window.open(url) from the popup (and from our chrome.tabs.create 583 + // shim, which delegates to window.open) through Peek's normal window 584 + // pipeline. Without this, Electron's default handler creates a bare 585 + // BrowserWindow that bypasses Peek's tile/page system; for chrome 586 + // extensions specifically, the call also returns null, breaking 587 + // Proton's auth-fork chain. 588 + win.webContents.setWindowOpenHandler(({ url: childUrl }) => { 589 + if (childUrl.startsWith('http://') || childUrl.startsWith('https://')) { 590 + DEBUG && console.log(`[chrome-ext] popup ${windowKey} → opening ${childUrl} in Peek`); 591 + if (invokeWindowOpen) { 592 + // ipc.ts derives the opener window via BrowserWindow.fromWebContents(ev.sender); 593 + // pass our popup's webContents so placement / parent-tracking works. 594 + invokeWindowOpen({ sender: win.webContents }, { 595 + source: `chrome-extension:${extensionId}`, 596 + url: childUrl, 597 + options: {}, 598 + }).catch(err => { 599 + console.error(`[chrome-ext] invokeWindowOpen failed for ${childUrl}:`, err); 600 + }); 601 + } 602 + return { action: 'deny' }; 603 + } 604 + return { action: 'allow' }; 559 605 }); 560 606 561 607 win.loadURL(entry.url).catch(error => {
+27
backend/electron/entry.ts
··· 315 315 DEBUG && console.log(`[webauthn] Injected Proton Pass webauthn.js into: ${url}`); 316 316 } 317 317 }); 318 + 319 + // Diagnostic: forward webview-guest console + open DevTools when in DEBUG 320 + // mode and the URL is a Proton property. Lets us see whether our 321 + // chrome.runtime polyfill is reaching the page and what the auth-ext page 322 + // is actually doing when it shows the "communication error" message. 323 + if (DEBUG) { 324 + contents.on('console-message', (event) => { 325 + try { 326 + const e = event as unknown as { level?: unknown; message?: unknown; lineNumber?: unknown; sourceId?: unknown }; 327 + const msg = String(e.message ?? ''); 328 + const url = contents.getURL(); 329 + // Only log Proton-related pages to avoid flood — the bridge is only relevant there. 330 + if (url.includes('proton.me') || url.includes('proton.dev') || msg.includes('peek:')) { 331 + console.log(`[webview:${url.slice(0, 80)}:${String(e.level)}] ${msg} (${String(e.sourceId)}:${String(e.lineNumber)})`); 332 + } 333 + } catch { /* logging must never throw */ } 334 + }); 335 + contents.on('did-finish-load', () => { 336 + const url = contents.getURL(); 337 + if (url.includes('proton.me') || url.includes('proton.dev')) { 338 + if (!contents.isDevToolsOpened()) { 339 + try { contents.openDevTools({ mode: 'detach', activate: false }); } 340 + catch { /* devtools open is best-effort */ } 341 + } 342 + } 343 + }); 344 + } 318 345 } 319 346 }); 320 347
+81 -12
scripts/patch-chrome-extensions.js
··· 144 144 } 145 145 146 146 try { 147 + var _wrapped = wrapWithPermissionsShim(_chrome, _shimPerms); 148 + // Use getter/no-op-setter rather than writable:false. Proton's 149 + // "extension API isolation" feature does globalThis.chrome = proxy 150 + // inside a forEach over global names; throwing here aborts the rest of 151 + // the forEach (and the BG init that follows). A silent setter lets the 152 + // assignment "succeed" without changing what the binding returns. 147 153 Object.defineProperty(globalThis, 'chrome', { 148 - value: wrapWithPermissionsShim(_chrome, _shimPerms), 149 - writable: false, configurable: false, enumerable: true, 154 + configurable: false, enumerable: true, 155 + get: function() { return _wrapped; }, 156 + set: function() { /* swallow — Proton's chrome rewrap is a no-op */ }, 150 157 }); 151 - _log('chrome wrapped with permissions Proxy (locked)'); 158 + _log('chrome wrapped with permissions Proxy (accessor-locked)'); 152 159 // Electron's native browser polyfill (in SW context) omits 'permissions' 153 160 // — it only exposes APIs Electron implements natively. Proton's BG calls 154 161 // browser.permissions.onAdded.addListener at sync init, throwing and ··· 186 193 var DEBUG_PERMISSIONS = false; 187 194 function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions:html]'].concat(Array.prototype.slice.call(arguments))); } 188 195 196 + // Surface popup-side unhandled errors / rejections to main stderr 197 + // via console.error. These would otherwise be swallowed by Proton's 198 + // Sentry handler before any visible UI feedback. 199 + try { 200 + window.addEventListener('error', function(ev) { 201 + try { 202 + var stack = ev.error && ev.error.stack ? ev.error.stack : '(no stack)'; 203 + console.error('[peek:popup-error]', ev.message, '@', ev.filename + ':' + ev.lineno + ':' + ev.colno, '\\n', stack); 204 + } catch(_) {} 205 + }, true); 206 + window.addEventListener('unhandledrejection', function(ev) { 207 + try { 208 + var r = ev.reason; 209 + var msg = r && r.message ? r.message : String(r); 210 + var stack = r && r.stack ? r.stack : '(no stack)'; 211 + console.error('[peek:popup-unhandledrejection]', msg, '\\n', stack); 212 + } catch(_) {} 213 + }, true); 214 + } catch(e) {} 215 + 189 216 function NoopEvent() { this._l = []; } 190 217 NoopEvent.prototype.addListener = function(fn) { this._l.push(fn); }; 191 218 NoopEvent.prototype.removeListener = function(fn) { this._l = this._l.filter(function(x) { return x !== fn; }); }; ··· 233 260 }; 234 261 globalThis.__peekPermissions = _permsObj; 235 262 263 + // chrome.tabs.create polyfill: Electron's native chrome.tabs in extension 264 + // popup BrowserWindow contexts exposes query() but not create(). Proton's 265 + // login-click handler calls chrome.tabs.create({url: forkAuthUrl}) and 266 + // throws TypeError if absent — its .finally then unconditionally 267 + // window.close()s the popup, so the user sees a popup that disappears with 268 + // no visible auth page. We delegate to window.open(url, '_blank'); the 269 + // popup BrowserWindow's setWindowOpenHandler routes through invokeWindowOpen 270 + // and opens the URL as a Peek page tile. 271 + var _fakeTabId = 1000; 272 + function _peekTabsCreate(props, callback) { 273 + var url = (props && props.url) || 'about:blank'; 274 + try { window.open(url, '_blank'); } catch(_) {} 275 + var tab = { id: ++_fakeTabId, index: 0, windowId: 0, active: true, url: url, title: '', highlighted: false, pinned: false, incognito: false, selected: false }; 276 + if (typeof callback === 'function') { try { callback(tab); } catch(_) {} } 277 + return Promise.resolve(tab); 278 + } 279 + 236 280 function _patchChrome(c) { 237 281 if (!c || typeof c !== 'object') return; 238 282 try { ··· 242 286 try { c.permissions = _permsObj; _log('installed via assignment'); } 243 287 catch(e2) { _log('install failed:', e.message, '/', e2.message); } 244 288 } 289 + 290 + try { 291 + if (c.tabs && typeof c.tabs.create !== 'function') { 292 + try { c.tabs.create = _peekTabsCreate; _log('installed chrome.tabs.create polyfill'); } 293 + catch(_) { 294 + try { Object.defineProperty(c.tabs, 'create', { value: _peekTabsCreate, writable: true, configurable: true, enumerable: true }); _log('installed chrome.tabs.create via defineProperty'); } 295 + catch(_) {} 296 + } 297 + } 298 + } catch(_) {} 299 + } 300 + 301 + // Install an accessor that always returns our patched chrome and 302 + // silently swallows reassignments. Proton's bundled "extension API 303 + // isolation" runs globalThis.chrome = proxy inside a forEach loop; 304 + // a throwing lock (writable:false) aborts that forEach and breaks 305 + // popup init. A no-op setter lets the assignment "succeed" without 306 + // actually replacing what the binding returns. 307 + function _installLock() { 308 + try { 309 + Object.defineProperty(globalThis, 'chrome', { 310 + configurable: false, enumerable: true, 311 + get: function() { return _real; }, 312 + set: function() { /* swallow — Proton's chrome rewrap is a no-op */ }, 313 + }); 314 + _log('chrome accessor-locked'); 315 + } catch(e) { _log('lock globalThis.chrome failed:', e.message); } 245 316 } 246 317 247 318 if (typeof chrome !== 'undefined' && chrome) { 248 319 _real = chrome; 249 320 _patchChrome(_real); 250 - // Lock globalThis.chrome so downstream code (e.g. webextension-polyfill) 251 - // can't replace the binding with a Proxy that hides permissions. The 252 - // underlying object is the same; only the binding is frozen. 253 - try { 254 - Object.defineProperty(globalThis, 'chrome', { value: _real, writable: false, configurable: false, enumerable: true }); 255 - _log('locked globalThis.chrome'); 256 - } catch(e) { _log('lock globalThis.chrome failed:', e.message); } 321 + _installLock(); 257 322 return; 258 323 } 259 324 260 - // chrome not yet defined. Install setter to capture Electron's assignment. 325 + // chrome not yet defined. Install accept-once setter to capture 326 + // Electron's assignment, then convert to a locked accessor. 261 327 try { 262 328 Object.defineProperty(globalThis, 'chrome', { 263 329 configurable: true, ··· 266 332 set: function(v) { 267 333 _real = v; 268 334 _patchChrome(_real); 335 + // Replace this accept-once accessor with the silent-swallow lock. 336 + _installLock(); 269 337 }, 270 338 }); 271 - _log('chrome setter installed'); 339 + _log('chrome accept-once setter installed'); 272 340 } catch(e) { _log('chrome setter install failed:', e.message); } 273 341 })(); 274 342 `; ··· 477 545 } catch(e) { _push({ kind: 'executeScript_wrap_failed', err: String(e && e.message || e) }); } 478 546 })(); 479 547 `; 548 + 480 549 481 550 function patchProtonPass() { 482 551 const extPath = path.join(EXT_DIR, 'proton-pass');