Something went wrong. Try again.
experiments in a post-browser web
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839#!/usr/bin/env node/** * Patch bundled Chrome extensions for Electron compatibility. * * Applies shims and patches to extension files that are needed because * Electron does not implement all Chrome extension APIs. Run this after * placing or updating extension files in resources/chrome-extensions/. * * What it does: * - Proton Pass: Prepends chrome.permissions, chrome.storage.session, * chrome.runtime.getBackgroundPage, and chrome.scripting shims to background.js * - Proton Pass: Prepends chrome.permissions shim to polyfills.js * (loaded by popup.html and settings.html before their entry scripts) * * Usage: * node scripts/patch-chrome-extensions.js * * Safe to run multiple times — checks for existing shim before patching. */
import fs from 'node:fs';import path from 'node:path';import { fileURLToPath } from 'node:url';import { getBgInjection as getRuntimeExternalBgInjection } from '../apps/desktop/main/chrome-api-polyfills/runtime-external.js';import { getPreloadScript as getScriptingPreloadScript } from '../apps/desktop/main/chrome-api-polyfills/scripting.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));const ROOT = path.resolve(__dirname, '..');const EXT_DIR = path.join(ROOT, 'apps', 'desktop', 'chrome-extensions');
const SHIM_MARKER = '/* Peek: chrome.permissions';const HTML_SHIM_MARKER = '<!--peek:permissions-->';const RUNTIME_EXTERNAL_BG_MARKER = '/* Peek: chrome.runtime.onMessageExternal polyfill';
// Unique sentinels around the whole BG-side shim block. Strip removes// everything between BEGIN and END (inclusive). Bulletproof — no IIFE-close// heuristics that can collide with bundled extension code.const SHIM_BLOCK_BEGIN = '/*PEEK_BG_SHIMS_BEGIN — do not remove or edit by hand; managed by scripts/patch-chrome-extensions.js*/\n';const SHIM_BLOCK_END = '\n/*PEEK_BG_SHIMS_END*/\n';
/** * The permissions shim. Electron does not implement chrome.permissions. * The webextension-polyfill used by Proton Pass detects globalThis.browser * and uses it directly (bypassing its chrome->browser wrapper), so we must * shim BOTH chrome.permissions AND browser.permissions. Supports both * Promise and callback invocation styles (webextension-polyfill uses * Chrome callback convention). Without this, the * service worker crashes at startup on permissions.onAdded.addListener(). */const PERMISSIONS_SHIM = `/* Peek: chrome.permissions shim for Electron compatibility (background SW). * Strategy: replace globalThis.chrome with a Proxy whose target is a plain * empty object {}. The Proxy's get trap returns our shim for 'permissions' * and delegates everything else to the real native chrome. After installing, * LOCK globalThis.chrome with writable:false / configurable:false so * Proton's bundled "extension API isolation" wrapper (a setTimeout(0) that * replaces globalThis.chrome with a Proxy returning errors for every prop * except 'app') CANNOT clobber us — that wrap would otherwise break * webextension-polyfill's lazy browser.permissions, halting BG init at * 'a8.permissions.onAdded.addListener' and so blocking autofill entirely. * * Why the empty-target shape: * 1. Electron's native chrome.permissions is a non-configurable, non-writable * property on the native chrome object. We can't redefine it via * defineProperty, and even mutating its methods doesn't help once a * downstream consumer wraps chrome in their own Proxy that hides * 'permissions' — the V8 invariant fires because the target property is * non-configurable but the consumer's get trap returns undefined. * 2. Replacing chrome with a Proxy whose target is {} means the target has * NO own properties — no invariants apply to anything. */(function() { var DEBUG_PERMISSIONS = false; function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions]'].concat(Array.prototype.slice.call(arguments))); }
function NoopEvent() { this._l = []; } NoopEvent.prototype.addListener = function(fn) { this._l.push(fn); }; NoopEvent.prototype.removeListener = function(fn) { this._l = this._l.filter(function(x) { return x !== fn; }); }; NoopEvent.prototype.hasListener = function(fn) { return this._l.indexOf(fn) !== -1; }; NoopEvent.prototype.hasListeners = function() { return this._l.length > 0; };
var _chrome = (typeof chrome !== 'undefined') ? chrome : null; if (!_chrome) return;
/* The polyfill methods that always grant. They reference _chrome (via runtime.getManifest) */ function _request(perms, callback) { _log('request called with:', JSON.stringify(perms)); try { delete _chrome.runtime.lastError; } catch(e) {} if (typeof callback === 'function') callback(true); return Promise.resolve(true); } function _contains(perms, callback) { _log('contains called with:', JSON.stringify(perms)); try { delete _chrome.runtime.lastError; } catch(e) {} if (typeof callback === 'function') callback(true); return Promise.resolve(true); } function _getAll(callback) { var result = { permissions: [], origins: [] }; try { if (_chrome.runtime && _chrome.runtime.getManifest) { var m = _chrome.runtime.getManifest(); result = { permissions: m.permissions || [], origins: m.host_permissions || [] }; } } catch(e) {} try { delete _chrome.runtime.lastError; } catch(e) {} if (typeof callback === 'function') callback(result); return Promise.resolve(result); } function _remove(perms, callback) { try { delete _chrome.runtime.lastError; } catch(e) {} if (typeof callback === 'function') callback(true); return Promise.resolve(true); }
var _shimPerms = { request: _request, contains: _contains, getAll: _getAll, remove: _remove, onAdded: new NoopEvent(), onRemoved: new NoopEvent(), };
/* Backup object — accessible from tests to verify the shim is loaded. */ globalThis.__peekPermissions = _shimPerms;
/* Build a Proxy wrapper for chrome with {} as target. * _target stores overrides — downstream polyfills (chrome.scripting etc.) * that fail to assign on nativeObj (read-only native props) get stored here * and served by the get trap before falling through to nativeObj. */ function wrapWithPermissionsShim(nativeObj, shimPerms) { if (!nativeObj || typeof nativeObj !== 'object') return nativeObj; return new Proxy({}, { get: function(_target, prop) { if (prop === 'permissions') return shimPerms; if (Object.prototype.hasOwnProperty.call(_target, prop)) return _target[prop]; var val = nativeObj[prop]; if (typeof val === 'function') return val.bind(nativeObj); return val; }, set: function(_target, prop, value) { if (prop === 'permissions') return true; /* swallow attempts to overwrite our shim */ try { nativeObj[prop] = value; if (nativeObj[prop] === value) return true; } catch(e) {} _target[prop] = value; return true; }, defineProperty: function(_target, prop, desc) { if (prop === 'permissions') return true; try { Object.defineProperty(nativeObj, prop, desc); return true; } catch(e) {} try { Object.defineProperty(_target, prop, desc); return true; } catch(e) {} return false; }, has: function(_target, prop) { return prop === 'permissions' || prop in _target || prop in nativeObj; }, ownKeys: function(_target) { var keys = []; try { keys = Reflect.ownKeys(nativeObj); } catch(e) {} var tKeys = Reflect.ownKeys(_target); for (var i = 0; i < tKeys.length; i++) { if (keys.indexOf(tKeys[i]) === -1) keys.push(tKeys[i]); } if (keys.indexOf('permissions') === -1) keys.push('permissions'); return keys; }, getOwnPropertyDescriptor: function(_target, prop) { if (prop === 'permissions') { return { value: shimPerms, writable: true, configurable: true, enumerable: true }; } if (Object.prototype.hasOwnProperty.call(_target, prop)) { return { value: _target[prop], writable: true, configurable: true, enumerable: true }; } try { return Object.getOwnPropertyDescriptor(nativeObj, prop); } catch(e) { return undefined; } }, }); }
try { var _wrapped = wrapWithPermissionsShim(_chrome, _shimPerms); // Use getter/no-op-setter rather than writable:false. Proton's // "extension API isolation" feature does globalThis.chrome = proxy // inside a forEach over global names; throwing here aborts the rest of // the forEach (and the BG init that follows). A silent setter lets the // assignment "succeed" without changing what the binding returns. Object.defineProperty(globalThis, 'chrome', { configurable: false, enumerable: true, get: function() { return _wrapped; }, set: function() { /* swallow — Proton's chrome rewrap is a no-op */ }, }); _log('chrome wrapped with permissions Proxy (accessor-locked)'); // Electron's native browser polyfill (in SW context) omits 'permissions' // — it only exposes APIs Electron implements natively. Proton's BG calls // browser.permissions.onAdded.addListener at sync init, throwing and // aborting BG before any chrome.runtime.onMessage handler is registered. // Plug in our shim on browser.permissions too. try { if (typeof globalThis.browser === 'object' && globalThis.browser && !globalThis.browser.permissions) { Object.defineProperty(globalThis.browser, 'permissions', { value: _shimPerms, writable: false, configurable: false, enumerable: true, }); _log('browser.permissions installed'); } } catch(e) { _log('browser.permissions install failed:', e.message); } } catch(e) { _log('chrome wrap failed:', e.message); }})();`;
/** * chrome.webRequest noop shim for the background service worker. * * Electron 43 does not register the `webRequest` module resource in a * service-worker (BLESSED_EXTENSION) context — the SW load logs * "NOTREACHED ... Module resource registered as \"webRequest\" not found" and * "No source for require(webRequest)" — so chrome.webRequest is `undefined`. * Proton's background.js declares the `webRequest` permission and, at sync * startup, calls chrome.webRequest.{onAuthRequired,onBeforeRequest,onCompleted, * onErrorOccurred}.addListener(). The first read of `.addListener` on the * undefined namespace throws `TypeError: Cannot read properties of undefined`, * the SW registration fails ("Service worker registration failed. Status * code: 15"), and the SW never runs — the popup can't read vault/auth state * (renders blank) and autofill never injects. * * Electron does not deliver chrome.webRequest events to extensions regardless, * so a noop event shim matches real behaviour AND unblocks SW startup. Same * philosophy as the permissions / storage.session shims above. Generalises to * any extension that declares webRequest and touches it at SW init. * * Runs AFTER PERMISSIONS_SHIM so globalThis.chrome is the permissions Proxy; * assigning chrome.webRequest routes through the Proxy set trap into its * override table and is served back by the get trap. */const WEBREQUEST_SHIM = `/* Peek: chrome.webRequest noop shim for Electron SW compatibility. */(function(){ var DEBUG_WEBREQUEST = false; function _log() { if (DEBUG_WEBREQUEST) console.log.apply(console, ['[peek:webRequest]'].concat(Array.prototype.slice.call(arguments))); } function NoopEvent(){ this._l = []; } NoopEvent.prototype.addListener = function(fn){ if (typeof fn === 'function') this._l.push(fn); }; NoopEvent.prototype.removeListener = function(fn){ var i = this._l.indexOf(fn); if (i >= 0) this._l.splice(i, 1); }; NoopEvent.prototype.hasListener = function(fn){ return this._l.indexOf(fn) >= 0; }; function makeWebRequest(){ return { onBeforeRequest: new NoopEvent(), onBeforeSendHeaders: new NoopEvent(), onSendHeaders: new NoopEvent(), onHeadersReceived: new NoopEvent(), onAuthRequired: new NoopEvent(), onBeforeRedirect: new NoopEvent(), onResponseStarted: new NoopEvent(), onCompleted: new NoopEvent(), onErrorOccurred: new NoopEvent(), onActionIgnored: new NoopEvent(), handlerBehaviorChanged: function(cb){ if (typeof cb === 'function') cb(); return Promise.resolve(); }, MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES: 20, }; } /* Backup — accessible from tests to verify the shim is loaded. */ globalThis.__peekWebRequest = makeWebRequest(); try { if (globalThis.chrome) { globalThis.chrome.webRequest = makeWebRequest(); _log('chrome.webRequest installed'); } } catch(e) { _log('chrome.webRequest install failed:', e && e.message); } try { var _b = globalThis.browser; if (typeof _b === 'object' && _b) { var _wr = makeWebRequest(); var _cur = _b.webRequest; if (!_cur) { // Pre-Electron-43: browser.webRequest absent entirely — install whole. try { _b.webRequest = _wr; } catch(e) {} if (!_b.webRequest) { try { Object.defineProperty(_b, 'webRequest', { value: _wr, writable: true, configurable: true, enumerable: true }); } catch(e) {} } _log('browser.webRequest installed (was absent)'); } else { // Electron 43 registers the webRequest namespace SHAPE on browser (all // the event property NAMES are own props) but leaves each event object // === undefined. The old "install only if webRequest absent" guard // therefore skipped, and Proton's browser.webRequest.onBeforeRequest // .addListener() dereferenced undefined and threw, killing the SW. // Fill only the empty slots so we don't clobber any real enums the // native stub carries. for (var k in _wr) { try { if (_cur[k] === undefined || _cur[k] === null) { try { _cur[k] = _wr[k]; } catch(e) { try { Object.defineProperty(_cur, k, { value: _wr[k], writable: true, configurable: true, enumerable: true }); } catch(_){} } } } catch(e) {} } _log('browser.webRequest event slots filled (Electron 43 stub)'); } } } catch(e) { _log('browser.webRequest install failed:', e && e.message); }})();`;
/** * Polyfills-only permissions shim. Same method-mutation strategy as the * background shim. Method mutation does not interact with browser.permissions * (which webextension-polyfill builds during popup.js execution) so the old * "black screen on early browser.permissions touch" hazard doesn't apply. *//** * Standalone shim file — written as `peek-permissions.js` into the extension * directory and referenced from each HTML entry point via <script src=...> * BEFORE polyfills.js. Inline scripts are blocked by the extension's * Manifest-V3 CSP (script-src 'self'), but external 'self' scripts are * allowed. Installs a setter on globalThis.chrome so we patch in * chrome.permissions the moment Electron defines chrome — before any * extension script can read it or wrap it in a Proxy. */const PEEK_PERMISSIONS_JS = `/* Peek: chrome.permissions early-install shim. See patch-chrome-extensions.js. */(function() { var DEBUG_PERMISSIONS = false; function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions:html]'].concat(Array.prototype.slice.call(arguments))); }
// Surface popup-side unhandled errors / rejections to main stderr // via console.error. These would otherwise be swallowed by Proton's // Sentry handler before any visible UI feedback. try { window.addEventListener('error', function(ev) { try { var stack = ev.error && ev.error.stack ? ev.error.stack : '(no stack)'; // For resource-load errors (img/script/link), ev.message is undefined. // ev.target identifies the failed element — dump its src/href + tag. var t = ev.target; var resourceInfo = ''; if (t && t !== window && t.nodeType === 1) { var src = t.src || t.href || '(no src/href)'; var tag = t.tagName || '(no tag)'; resourceInfo = ' resource=' + tag + ' src=' + src; } console.error('[peek:popup-error]', ev.message, '@', ev.filename + ':' + ev.lineno + ':' + ev.colno, resourceInfo, '\\n', stack); } catch(_) {} }, true); window.addEventListener('unhandledrejection', function(ev) { try { var r = ev.reason; var msg = r && r.message ? r.message : String(r); var stack = r && r.stack ? r.stack : '(no stack)'; console.error('[peek:popup-unhandledrejection]', msg, '\\n', stack); } catch(_) {} }, true); } catch(e) {}
function NoopEvent() { this._l = []; } NoopEvent.prototype.addListener = function(fn) { this._l.push(fn); }; NoopEvent.prototype.removeListener = function(fn) { this._l = this._l.filter(function(x) { return x !== fn; }); }; NoopEvent.prototype.hasListener = function(fn) { return this._l.indexOf(fn) !== -1; }; NoopEvent.prototype.hasListeners = function() { return this._l.length > 0; };
function _clearLastError(c) { try { delete c.runtime.lastError; } catch(e) {} }
var _real; function _getChrome() { return _real; }
var _permsObj = { request: function(perms, callback) { _log('request:', JSON.stringify(perms)); _clearLastError(_getChrome()); if (typeof callback === 'function') callback(true); return Promise.resolve(true); }, contains: function(perms, callback) { _log('contains:', JSON.stringify(perms)); _clearLastError(_getChrome()); if (typeof callback === 'function') callback(true); return Promise.resolve(true); }, getAll: function(callback) { var c = _getChrome(); var result = { permissions: [], origins: [] }; try { if (c && c.runtime && c.runtime.getManifest) { var m = c.runtime.getManifest(); result = { permissions: m.permissions || [], origins: m.host_permissions || [] }; } } catch(e) {} _clearLastError(c); if (typeof callback === 'function') callback(result); return Promise.resolve(result); }, remove: function(perms, callback) { _clearLastError(_getChrome()); if (typeof callback === 'function') callback(true); return Promise.resolve(true); }, onAdded: new NoopEvent(), onRemoved: new NoopEvent(), }; globalThis.__peekPermissions = _permsObj;
// chrome.tabs.create polyfill: Electron's native chrome.tabs in extension // popup BrowserWindow contexts exposes query() but not create(). Proton's // login-click handler calls chrome.tabs.create({url: forkAuthUrl}) and // throws TypeError if absent — its .finally then unconditionally // window.close()s the popup, so the user sees a popup that disappears with // no visible auth page. We delegate to window.open(url, '_blank'); the // popup BrowserWindow's setWindowOpenHandler routes through invokeWindowOpen // and opens the URL as a Peek page tile. var _fakeTabId = 1000; function _peekTabsCreate(props, callback) { var url = (props && props.url) || 'about:blank'; try { window.open(url, '_blank'); } catch(_) {} var tab = { id: ++_fakeTabId, index: 0, windowId: 0, active: true, url: url, title: '', highlighted: false, pinned: false, incognito: false, selected: false }; if (typeof callback === 'function') { try { callback(tab); } catch(_) {} } return Promise.resolve(tab); }
function _patchChrome(c) { if (!c || typeof c !== 'object') return; try { Object.defineProperty(c, 'permissions', { value: _permsObj, writable: true, configurable: true, enumerable: true }); _log('installed chrome.permissions on captured chrome'); } catch(e) { try { c.permissions = _permsObj; _log('installed via assignment'); } catch(e2) { _log('install failed:', e.message, '/', e2.message); } }
try { if (c.tabs && typeof c.tabs.create !== 'function') { try { c.tabs.create = _peekTabsCreate; _log('installed chrome.tabs.create polyfill'); } catch(_) { try { Object.defineProperty(c.tabs, 'create', { value: _peekTabsCreate, writable: true, configurable: true, enumerable: true }); _log('installed chrome.tabs.create via defineProperty'); } catch(_) {} } } } catch(_) {} }
// Install an accessor that always returns our patched chrome and // silently swallows reassignments. Proton's bundled "extension API // isolation" runs globalThis.chrome = proxy inside a forEach loop; // a throwing lock (writable:false) aborts that forEach and breaks // popup init. A no-op setter lets the assignment "succeed" without // actually replacing what the binding returns. function _installLock() { try { Object.defineProperty(globalThis, 'chrome', { configurable: false, enumerable: true, get: function() { return _real; }, set: function() { /* swallow — Proton's chrome rewrap is a no-op */ }, }); _log('chrome accessor-locked'); } catch(e) { _log('lock globalThis.chrome failed:', e.message); } }
if (typeof chrome !== 'undefined' && chrome) { _real = chrome; _patchChrome(_real); _installLock(); return; }
// chrome not yet defined. Install accept-once setter to capture // Electron's assignment, then convert to a locked accessor. try { Object.defineProperty(globalThis, 'chrome', { configurable: true, enumerable: true, get: function() { return _real; }, set: function(v) { _real = v; _patchChrome(_real); // Replace this accept-once accessor with the silent-swallow lock. _installLock(); }, }); _log('chrome accept-once setter installed'); } catch(e) { _log('chrome setter install failed:', e.message); }})();`;const PEEK_PERMISSIONS_SCRIPT_TAG = '<!--peek:permissions--><script src="peek-permissions.js" charset="UTF-8"></script>';
/** * chrome.storage.session in-memory shim. Electron does not provide * chrome.storage.session. Without this, the background service worker * fails to initialize its storage layer and settings pages stay blank. */const STORAGE_SESSION_SHIM = `// --- chrome.storage.session in-memory shim ---(function() { if (typeof chrome !== 'undefined' && chrome.storage) { if (!chrome.storage.session) { const _data = {}; const _listeners = new Set(); chrome.storage.session = { get(keys) { return new Promise(resolve => { if (keys === null || keys === undefined) { resolve({..._data}); return; } if (typeof keys === 'string') keys = [keys]; if (Array.isArray(keys)) { const result = {}; for (const k of keys) { if (k in _data) result[k] = _data[k]; } resolve(result); return; } const result = {}; for (const [k, def] of Object.entries(keys)) { result[k] = k in _data ? _data[k] : def; } resolve(result); }); }, set(items) { return new Promise(resolve => { const changes = {}; for (const [k, v] of Object.entries(items)) { const oldValue = _data[k]; _data[k] = v; changes[k] = { newValue: v }; if (oldValue !== undefined) changes[k].oldValue = oldValue; } if (Object.keys(changes).length > 0) { for (const l of _listeners) { try { l(changes, 'session'); } catch(e) {} } } resolve(); }); }, remove(keys) { return new Promise(resolve => { if (typeof keys === 'string') keys = [keys]; const changes = {}; for (const k of keys) { if (k in _data) { changes[k] = { oldValue: _data[k] }; delete _data[k]; } } if (Object.keys(changes).length > 0) { for (const l of _listeners) { try { l(changes, 'session'); } catch(e) {} } } resolve(); }); }, clear() { return new Promise(resolve => { const changes = {}; for (const [k, v] of Object.entries(_data)) { changes[k] = { oldValue: v }; delete _data[k]; } if (Object.keys(changes).length > 0) { for (const l of _listeners) { try { l(changes, 'session'); } catch(e) {} } } resolve(); }); }, getBytesInUse(keys) { return new Promise(resolve => { if (keys === null || keys === undefined) { resolve(JSON.stringify(_data).length * 2); return; } if (typeof keys === 'string') keys = [keys]; let total = 0; for (const k of keys) { if (k in _data) total += JSON.stringify(k).length * 2 + JSON.stringify(_data[k]).length * 2; } resolve(total); }); }, setAccessLevel() { return Promise.resolve(); }, onChanged: { addListener(cb) { _listeners.add(cb); }, removeListener(cb) { _listeners.delete(cb); }, hasListener(cb) { return _listeners.has(cb); }, hasListeners() { return _listeners.size > 0; }, }, QUOTA_BYTES: 10485760, }; } }})();`;
/** * chrome.runtime.getBackgroundPage shim. Electron does not implement this API. * Without it, the background page detection fails and storage operations * relay via sendMessage back to itself in a circular failure loop. */const GET_BACKGROUND_PAGE_SHIM = `// --- chrome.runtime.getBackgroundPage shim ---(function() { if (typeof chrome !== 'undefined' && chrome.runtime && !chrome.runtime.getBackgroundPage) { chrome.runtime.getBackgroundPage = function() { return Promise.resolve(typeof self !== 'undefined' ? self : globalThis); }; }})();`;
/** * Optional diagnostic shim — only injected when env var * PEEK_PROTON_DIAG=1 is set at patch time. Wraps chrome.runtime.onMessage, * chrome.runtime.sendMessage, and chrome.scripting.executeScript with * lightweight loggers that stash entries in chrome.storage.local under * '__peekProtonDiag'. Read from any extension-context page (popup, * settings, etc.) via chrome.storage.local.get(['__peekProtonDiag']). */const SCRIPTING_DIAG_SHIM = `// --- Peek diagnostic shim (PROTON_DIAG) ---(function() { if (typeof chrome === 'undefined') return; var _log = []; var _flushScheduled = false; function _push(entry) { entry.t = Date.now(); _log.push(entry); globalThis._peekProtonDiag = _log; if (!_flushScheduled && chrome.storage && chrome.storage.local) { _flushScheduled = true; Promise.resolve().then(function() { _flushScheduled = false; try { chrome.storage.local.set({ __peekProtonDiag: JSON.stringify(_log) }); } catch(e) {} }); } } _push({ kind: 'shim_installed' });
// Capture top-level errors during BG init so we see if Proton's bundle // throws before reaching chrome.runtime.onMessage.addListener. try { self.addEventListener('error', function(ev) { _push({ kind: 'sw_error', message: ev.message || String(ev), filename: ev.filename, lineno: ev.lineno, colno: ev.colno }); }); self.addEventListener('unhandledrejection', function(ev) { _push({ kind: 'sw_unhandled_rejection', reason: String(ev.reason && ev.reason.message || ev.reason) }); }); _push({ kind: 'error_handlers_installed' }); } catch(e) { _push({ kind: 'error_handler_install_failed', err: String(e && e.message || e) }); }
// Wrap chrome.runtime.onMessage.addListener — log every dispatch. try { var _origAdd = chrome.runtime.onMessage.addListener.bind(chrome.runtime.onMessage); chrome.runtime.onMessage.addListener = function(fn) { _push({ kind: 'addListener_called', fnName: fn && fn.name }); var wrapped = function(message, sender, sendResponse) { var msgType = message && message.type ? String(message.type) : '(no-type)'; _push({ kind: 'onMessage', msgType: msgType, sender: { url: sender && sender.url, tabId: sender && sender.tab && sender.tab.id, frameId: sender && sender.frameId } }); try { return fn(message, sender, sendResponse); } catch(e) { _push({ kind: 'onMessage_handler_threw', msgType: msgType, err: String(e && e.message || e) }); throw e; } }; return _origAdd(wrapped); }; } catch(e) { _push({ kind: 'addListener_wrap_failed', err: String(e && e.message || e) }); }
// Probe chrome state at various tick boundaries so we can see when (if ever) // Proton's allowProxy wrap fires. function _probe(label) { try { var browserKeys = []; try { if (globalThis.browser) browserKeys = Object.keys(globalThis.browser).slice(0, 30); } catch {} var info = { kind: 'probe', label: label, typeofChrome: typeof chrome, typeofRuntime: typeof chrome.runtime, chromePermissions: typeof chrome.permissions, chromePermsOnAdded: typeof chrome.permissions?.onAdded, typeofBrowser: typeof globalThis.browser, browserKeys: browserKeys, browserPermissions: typeof globalThis.browser?.permissions, browserPermsOnAdded: typeof globalThis.browser?.permissions?.onAdded, }; _push(info); } catch (e) { _push({ kind: 'probe_threw', label: label, err: String(e && e.message || e) }); } } _probe('sync_after_install'); Promise.resolve().then(function() { _probe('microtask_after_install'); }); setTimeout(function() { _probe('timeout0_after_install'); }, 0); setTimeout(function() { _probe('timeout500_after_install'); }, 500);
// Wrap chrome.scripting.executeScript — log every call + result. try { var _origExec = chrome.scripting && chrome.scripting.executeScript; if (_origExec) { chrome.scripting.executeScript = function(opts) { var summary = { kind: 'executeScript', target: opts && opts.target, files: opts && opts.files, hasFunc: !!(opts && opts.func) }; _push(summary); try { var p = _origExec.call(chrome.scripting, opts); if (p && typeof p.then === 'function') { return p.then(function(r) { _push({ kind: 'executeScript_result', target: opts && opts.target, files: opts && opts.files, ok: true }); return r; }) .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; }); } return p; } catch(e) { _push({ kind: 'executeScript_threw', err: String(e && e.message || e) }); throw e; } }; } else { _push({ kind: 'no_chrome_scripting' }); } } catch(e) { _push({ kind: 'executeScript_wrap_failed', err: String(e && e.message || e) }); }})();`;
function patchProtonPass() { const extPath = path.join(EXT_DIR, 'proton-pass');
if (!fs.existsSync(extPath)) { console.log('[patch] Proton Pass not found, skipping'); return; }
const bgPath = path.join(extPath, 'background.js'); if (!fs.existsSync(bgPath)) { console.warn('[patch] Proton Pass background.js not found'); return; }
let content = fs.readFileSync(bgPath, 'utf-8'); let patched = false;
// If the BG shim block is already present, replace it wholesale rather // than try to incrementally amend. The PEEK_BG_SHIMS_BEGIN/END sentinels // make this trivially safe: strip everything between them, then re-emit. if (content.includes(SHIM_BLOCK_BEGIN)) { const beginIdx = content.indexOf(SHIM_BLOCK_BEGIN); const endIdx = content.indexOf(SHIM_BLOCK_END, beginIdx); if (endIdx !== -1) { content = content.slice(0, beginIdx) + content.slice(endIdx + SHIM_BLOCK_END.length); } else { console.warn('[patch] Proton Pass background.js: BEGIN sentinel without END — refusing to patch (manual fix required)'); return; } patched = true; }
// Build the full shim block: permissions + storage + getBackgroundPage // (+ optional diag) + runtime-external. Wrap in BEGIN/END sentinels for // deterministic strip on subsequent runs. const diag = process.env.PEEK_PROTON_DIAG === '1' ? SCRIPTING_DIAG_SHIM : ''; const block = SHIM_BLOCK_BEGIN + PERMISSIONS_SHIM + WEBREQUEST_SHIM + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM + diag + getRuntimeExternalBgInjection() + getScriptingPreloadScript() + SHIM_BLOCK_END; if (!content.startsWith(SHIM_BLOCK_BEGIN)) { content = block + content; patched = true; }
if (!patched) { console.log('[patch] Proton Pass background.js already patched'); return; }
fs.writeFileSync(bgPath, content); console.log('[patch] Proton Pass background.js patched with shims');
// --- Write peek-permissions.js into the extension directory --- // Manifest-V3 CSP blocks inline <script>, so we ship the shim as a real file // referenced via <script src> from each HTML entry. CSP allows 'self'. const peekJsPath = path.join(extPath, 'peek-permissions.js'); fs.writeFileSync(peekJsPath, PEEK_PERMISSIONS_JS); console.log('[patch] Wrote peek-permissions.js');
// --- Copy peek-bridge.html + peek-bridge.js into the extension directory --- // The bridge page is loaded as a hidden BrowserWindow at // chrome-extension://<ext-id>/peek-bridge.html by chrome-extensions.ts. // Extension pages (chrome-extension:// origin) have native same-extension // chrome.runtime.connect() which reaches the BG SW — the cross-origin path // that Electron 40 does NOT implement for external pages. // // The script lives in a separate .js file (referenced via <script src>) // because MV3 extensions ship a CSP `script-src 'self' 'wasm-unsafe-eval'` // that blocks inline scripts even on extension-origin pages. const polyfillsDir = path.join(ROOT, 'apps', 'desktop', 'main', 'chrome-api-polyfills'); for (const file of ['peek-bridge.html', 'peek-bridge.js', 'peek-relay.js', 'peek-cs-compat.js']) { const srcPath = path.join(polyfillsDir, file); const dstPath = path.join(extPath, file); if (fs.existsSync(srcPath)) { fs.copyFileSync(srcPath, dstPath); console.log(`[patch] Copied ${file} to extension directory`); } else { console.warn(`[patch] WARNING: ${file} source not found at`, srcPath); } }
// --- Add peek-cs-compat.js to manifest content_scripts --- // The ISOLATED-world sendMessage shim must run before the extension's own // content scripts so it wraps chrome.runtime.sendMessage before any call. const manifestPath = path.join(extPath, 'manifest.json'); if (fs.existsSync(manifestPath)) { try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); const CS_COMPAT_ENTRY_MARKER = 'peek-cs-compat.js'; const hasCompat = (manifest.content_scripts || []).some( cs => cs.js && cs.js.includes(CS_COMPAT_ENTRY_MARKER) ); if (!hasCompat) { if (!manifest.content_scripts) manifest.content_scripts = []; manifest.content_scripts.unshift({ matches: ['https://*/*', 'http://*/*'], js: [CS_COMPAT_ENTRY_MARKER], all_frames: true, run_at: 'document_start', }); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); console.log('[patch] Added peek-cs-compat.js to manifest content_scripts'); } else { console.log('[patch] peek-cs-compat.js already in manifest content_scripts'); } } catch (e) { console.error('[patch] Failed to patch manifest:', e.message); } }
// --- Patch HTML entry points (popup.html, settings.html, etc.) --- // Inject <script src="peek-permissions.js"> right after <body>, BEFORE // polyfills.js / popup.js script tags. Our shim runs first, installs a // setter on globalThis.chrome, and patches in chrome.permissions the moment // Electron defines chrome — before any extension script reads it or wraps // it in a Proxy. const HTML_FILES = [ 'popup.html', 'settings.html', 'onboarding.html', 'internal.html', 'notification.html', 'dropdown.html', ]; for (const htmlFile of HTML_FILES) { const htmlPath = path.join(extPath, htmlFile); if (!fs.existsSync(htmlPath)) continue; let html = fs.readFileSync(htmlPath, 'utf-8'); if (html.includes(HTML_SHIM_MARKER)) { console.log(`[patch] Proton Pass ${htmlFile} already patched`); continue; } const m = html.match(/<body[^>]*>/i); if (!m) { console.warn(`[patch] WARNING: ${htmlFile} has no <body> tag`); continue; } const insertAt = m.index + m[0].length; html = html.slice(0, insertAt) + '\n' + PEEK_PERMISSIONS_SCRIPT_TAG + '\n' + html.slice(insertAt); fs.writeFileSync(htmlPath, html); console.log(`[patch] Proton Pass ${htmlFile} patched with peek-permissions script tag`); }
// Verify required files exist const required = ['polyfills.js', 'popup.html', 'settings.html']; const missing = required.filter(f => !fs.existsSync(path.join(extPath, f))); if (missing.length > 0) { console.warn(`[patch] WARNING: Proton Pass missing files: ${missing.join(', ')}`); console.warn('[patch] Re-extract the extension from CRX to get missing files.'); }}
// Run patchesconsole.log('[patch] Patching Chrome extensions for Electron compatibility...');patchProtonPass();console.log('[patch] Done');