#!/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 = '';
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 ';
/**
* 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