experiments in a post-browser web
1#!/usr/bin/env node
2/**
3 * Patch bundled Chrome extensions for Electron compatibility.
4 *
5 * Applies shims and patches to extension files that are needed because
6 * Electron does not implement all Chrome extension APIs. Run this after
7 * placing or updating extension files in resources/chrome-extensions/.
8 *
9 * What it does:
10 * - Proton Pass: Prepends chrome.permissions, chrome.storage.session, and
11 * chrome.runtime.getBackgroundPage shims to background.js
12 * - Proton Pass: Prepends chrome.permissions shim to polyfills.js
13 * (loaded by popup.html and settings.html before their entry scripts)
14 *
15 * Usage:
16 * node scripts/patch-chrome-extensions.js
17 *
18 * Safe to run multiple times — checks for existing shim before patching.
19 */
20
21import fs from 'node:fs';
22import path from 'node:path';
23import { fileURLToPath } from 'node:url';
24
25const __dirname = path.dirname(fileURLToPath(import.meta.url));
26const ROOT = path.resolve(__dirname, '..');
27const EXT_DIR = path.join(ROOT, 'chrome-extensions');
28
29const SHIM_MARKER = '/* Peek: chrome.permissions';
30
31/**
32 * The permissions shim. Electron does not implement chrome.permissions.
33 * The webextension-polyfill used by Proton Pass detects globalThis.browser
34 * and uses it directly (bypassing its chrome->browser wrapper), so we must
35 * shim BOTH chrome.permissions AND browser.permissions. Supports both
36 * Promise and callback invocation styles (webextension-polyfill uses
37 * Chrome callback convention). Without this, the
38 * service worker crashes at startup on permissions.onAdded.addListener().
39 */
40const PERMISSIONS_SHIM = `/* Peek: chrome.permissions + browser.permissions shim for Electron compatibility.
41 * Completely replaces chrome.permissions with our own object BEFORE any extension code runs.
42 * The webextension-polyfill captures the original chrome ref before Proton's Proxy replacement,
43 * so our shim on the original chrome object is what the polyfill will use.
44 * No polling needed — we run first, set non-configurable, and save a backup ref. */
45(function() {
46 var DEBUG_PERMISSIONS = false;
47 function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions]'].concat(Array.prototype.slice.call(arguments))); }
48
49 function NoopEvent() { this._l = []; }
50 NoopEvent.prototype.addListener = function(fn) { this._l.push(fn); };
51 NoopEvent.prototype.removeListener = function(fn) { this._l = this._l.filter(function(x) { return x !== fn; }); };
52 NoopEvent.prototype.hasListener = function(fn) { return this._l.indexOf(fn) !== -1; };
53 NoopEvent.prototype.hasListeners = function() { return this._l.length > 0; };
54
55 /* Save reference to the REAL chrome object before anything can replace it */
56 var _chrome = (typeof chrome !== 'undefined') ? chrome : null;
57 if (!_chrome) return;
58
59 var _permsObj = {
60 contains: function(perms, callback) {
61 _log('contains called with:', JSON.stringify(perms));
62 try { delete _chrome.runtime.lastError; } catch(e) {}
63 _log('contains returning true');
64 if (typeof callback === 'function') callback(true);
65 return Promise.resolve(true);
66 },
67 getAll: function(callback) {
68 _log('getAll called');
69 var result = { permissions: [], origins: [] };
70 try {
71 if (_chrome.runtime && _chrome.runtime.getManifest) {
72 var m = _chrome.runtime.getManifest();
73 result = { permissions: m.permissions || [], origins: m.host_permissions || [] };
74 }
75 } catch(e) {}
76 try { delete _chrome.runtime.lastError; } catch(e) {}
77 _log('getAll returning:', JSON.stringify(result));
78 if (typeof callback === 'function') callback(result);
79 return Promise.resolve(result);
80 },
81 request: function(perms, callback) {
82 _log('request called with:', JSON.stringify(perms));
83 try { delete _chrome.runtime.lastError; } catch(e) {}
84 _log('request returning true');
85 if (typeof callback === 'function') callback(true);
86 return Promise.resolve(true);
87 },
88 remove: function(perms, callback) {
89 _log('remove called with:', JSON.stringify(perms));
90 try { delete _chrome.runtime.lastError; } catch(e) {}
91 _log('remove returning true');
92 if (typeof callback === 'function') callback(true);
93 return Promise.resolve(true);
94 },
95 onAdded: new NoopEvent(),
96 onRemoved: new NoopEvent()
97 };
98
99 /* Save backup ref so we can verify our shim is being used */
100 globalThis.__peekPermissions = _permsObj;
101
102 /* Strategy: completely replace chrome.permissions with our object.
103 * Try non-configurable defineProperty first (prevents future overwrites).
104 * If that fails (native binding), delete and retry, then fall back to assignment. */
105 function installPermissions(root, name) {
106 if (!root || typeof root !== 'object') return false;
107 /* Attempt 1: defineProperty non-configurable */
108 try {
109 Object.defineProperty(root, name, { value: _permsObj, writable: false, configurable: false, enumerable: true });
110 _log('installed on', name, 'via defineProperty (non-configurable)');
111 return true;
112 } catch(e) { _log('defineProperty failed on', name, ':', e.message); }
113 /* Attempt 2: delete existing then defineProperty */
114 try {
115 delete root[name];
116 Object.defineProperty(root, name, { value: _permsObj, writable: false, configurable: false, enumerable: true });
117 _log('installed on', name, 'via delete+defineProperty');
118 return true;
119 } catch(e) { _log('delete+defineProperty failed on', name, ':', e.message); }
120 /* Attempt 3: direct assignment */
121 try {
122 root[name] = _permsObj;
123 _log('installed on', name, 'via direct assignment');
124 return true;
125 } catch(e) { _log('direct assignment failed on', name, ':', e.message); }
126 return false;
127 }
128
129 var installed = installPermissions(_chrome, 'permissions');
130 _log('shim installed on chrome.permissions:', installed, 'chrome.permissions.request:', typeof _chrome.permissions.request);
131
132 if (typeof browser !== 'undefined' && browser) {
133 installPermissions(browser, 'permissions');
134 }
135})();
136`;
137
138/**
139 * Polyfills-only permissions shim. Unlike the background.js shim, this must
140 * NOT touch browser.permissions because polyfills.js runs before
141 * webextension-polyfill in popup/settings pages. Setting browser.permissions
142 * before the polyfill runs causes it to detect an incomplete browser object,
143 * leading to a black screen.
144 */
145const POLYFILLS_PERMISSIONS_SHIM = `/* Peek: chrome.permissions shim for Electron compatibility (polyfills.js).
146 * Completely replaces chrome.permissions before any extension code runs.
147 * Does NOT touch browser.permissions — polyfills.js runs before webextension-polyfill
148 * in popup/settings pages; setting browser.permissions early causes black screen.
149 * No polling needed — we run first and set non-configurable. */
150(function() {
151 var DEBUG_PERMISSIONS = false;
152 function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions:polyfills]'].concat(Array.prototype.slice.call(arguments))); }
153
154 function NoopEvent() { this._l = []; }
155 NoopEvent.prototype.addListener = function(fn) { this._l.push(fn); };
156 NoopEvent.prototype.removeListener = function(fn) { this._l = this._l.filter(function(x) { return x !== fn; }); };
157 NoopEvent.prototype.hasListener = function(fn) { return this._l.indexOf(fn) !== -1; };
158 NoopEvent.prototype.hasListeners = function() { return this._l.length > 0; };
159
160 var _chrome = (typeof chrome !== 'undefined') ? chrome : null;
161 if (!_chrome) return;
162
163 var _permsObj = {
164 contains: function(perms, callback) {
165 _log('contains called with:', JSON.stringify(perms));
166 try { delete _chrome.runtime.lastError; } catch(e) {}
167 _log('contains returning true');
168 if (typeof callback === 'function') callback(true);
169 return Promise.resolve(true);
170 },
171 getAll: function(callback) {
172 _log('getAll called');
173 var result = { permissions: [], origins: [] };
174 try {
175 if (_chrome.runtime && _chrome.runtime.getManifest) {
176 var m = _chrome.runtime.getManifest();
177 result = { permissions: m.permissions || [], origins: m.host_permissions || [] };
178 }
179 } catch(e) {}
180 try { delete _chrome.runtime.lastError; } catch(e) {}
181 _log('getAll returning:', JSON.stringify(result));
182 if (typeof callback === 'function') callback(result);
183 return Promise.resolve(result);
184 },
185 request: function(perms, callback) {
186 _log('request called with:', JSON.stringify(perms));
187 try { delete _chrome.runtime.lastError; } catch(e) {}
188 _log('request returning true');
189 if (typeof callback === 'function') callback(true);
190 return Promise.resolve(true);
191 },
192 remove: function(perms, callback) {
193 _log('remove called with:', JSON.stringify(perms));
194 try { delete _chrome.runtime.lastError; } catch(e) {}
195 _log('remove returning true');
196 if (typeof callback === 'function') callback(true);
197 return Promise.resolve(true);
198 },
199 onAdded: new NoopEvent(),
200 onRemoved: new NoopEvent()
201 };
202
203 globalThis.__peekPermissions = _permsObj;
204
205 function installPermissions(root, name) {
206 if (!root || typeof root !== 'object') return false;
207 try {
208 Object.defineProperty(root, name, { value: _permsObj, writable: false, configurable: false, enumerable: true });
209 _log('installed via defineProperty (non-configurable)');
210 return true;
211 } catch(e) { _log('defineProperty failed:', e.message); }
212 try {
213 delete root[name];
214 Object.defineProperty(root, name, { value: _permsObj, writable: false, configurable: false, enumerable: true });
215 _log('installed via delete+defineProperty');
216 return true;
217 } catch(e) { _log('delete+defineProperty failed:', e.message); }
218 try {
219 root[name] = _permsObj;
220 _log('installed via direct assignment');
221 return true;
222 } catch(e) { _log('direct assignment failed:', e.message); }
223 return false;
224 }
225
226 var installed = installPermissions(_chrome, 'permissions');
227 _log('shim installed:', installed, 'chrome.permissions.request:', typeof _chrome.permissions.request);
228})();
229`;
230
231/**
232 * chrome.storage.session in-memory shim. Electron does not provide
233 * chrome.storage.session. Without this, the background service worker
234 * fails to initialize its storage layer and settings pages stay blank.
235 */
236const STORAGE_SESSION_SHIM = `// --- chrome.storage.session in-memory shim ---
237(function() {
238 if (typeof chrome !== 'undefined' && chrome.storage) {
239 if (!chrome.storage.session) {
240 const _data = {};
241 const _listeners = new Set();
242 chrome.storage.session = {
243 get(keys) {
244 return new Promise(resolve => {
245 if (keys === null || keys === undefined) { resolve({..._data}); return; }
246 if (typeof keys === 'string') keys = [keys];
247 if (Array.isArray(keys)) {
248 const result = {};
249 for (const k of keys) { if (k in _data) result[k] = _data[k]; }
250 resolve(result); return;
251 }
252 const result = {};
253 for (const [k, def] of Object.entries(keys)) { result[k] = k in _data ? _data[k] : def; }
254 resolve(result);
255 });
256 },
257 set(items) {
258 return new Promise(resolve => {
259 const changes = {};
260 for (const [k, v] of Object.entries(items)) {
261 const oldValue = _data[k]; _data[k] = v;
262 changes[k] = { newValue: v };
263 if (oldValue !== undefined) changes[k].oldValue = oldValue;
264 }
265 if (Object.keys(changes).length > 0) { for (const l of _listeners) { try { l(changes, 'session'); } catch(e) {} } }
266 resolve();
267 });
268 },
269 remove(keys) {
270 return new Promise(resolve => {
271 if (typeof keys === 'string') keys = [keys];
272 const changes = {};
273 for (const k of keys) { if (k in _data) { changes[k] = { oldValue: _data[k] }; delete _data[k]; } }
274 if (Object.keys(changes).length > 0) { for (const l of _listeners) { try { l(changes, 'session'); } catch(e) {} } }
275 resolve();
276 });
277 },
278 clear() {
279 return new Promise(resolve => {
280 const changes = {};
281 for (const [k, v] of Object.entries(_data)) { changes[k] = { oldValue: v }; delete _data[k]; }
282 if (Object.keys(changes).length > 0) { for (const l of _listeners) { try { l(changes, 'session'); } catch(e) {} } }
283 resolve();
284 });
285 },
286 getBytesInUse(keys) {
287 return new Promise(resolve => {
288 if (keys === null || keys === undefined) { resolve(JSON.stringify(_data).length * 2); return; }
289 if (typeof keys === 'string') keys = [keys];
290 let total = 0;
291 for (const k of keys) { if (k in _data) total += JSON.stringify(k).length * 2 + JSON.stringify(_data[k]).length * 2; }
292 resolve(total);
293 });
294 },
295 setAccessLevel() { return Promise.resolve(); },
296 onChanged: {
297 addListener(cb) { _listeners.add(cb); },
298 removeListener(cb) { _listeners.delete(cb); },
299 hasListener(cb) { return _listeners.has(cb); },
300 hasListeners() { return _listeners.size > 0; },
301 },
302 QUOTA_BYTES: 10485760,
303 };
304 }
305 }
306})();
307`;
308
309/**
310 * chrome.runtime.getBackgroundPage shim. Electron does not implement this API.
311 * Without it, the background page detection fails and storage operations
312 * relay via sendMessage back to itself in a circular failure loop.
313 */
314const GET_BACKGROUND_PAGE_SHIM = `// --- chrome.runtime.getBackgroundPage shim ---
315(function() {
316 if (typeof chrome !== 'undefined' && chrome.runtime && !chrome.runtime.getBackgroundPage) {
317 chrome.runtime.getBackgroundPage = function() {
318 return Promise.resolve(typeof self !== 'undefined' ? self : globalThis);
319 };
320 }
321})();
322`;
323
324function patchProtonPass() {
325 const extPath = path.join(EXT_DIR, 'proton-pass');
326
327 if (!fs.existsSync(extPath)) {
328 console.log('[patch] Proton Pass not found, skipping');
329 return;
330 }
331
332 const bgPath = path.join(extPath, 'background.js');
333 if (!fs.existsSync(bgPath)) {
334 console.warn('[patch] Proton Pass background.js not found');
335 return;
336 }
337
338 let content = fs.readFileSync(bgPath, 'utf-8');
339 let patched = false;
340
341 // Check each shim independently so re-running after adding new shims works
342 if (!content.includes(SHIM_MARKER)) {
343 content = PERMISSIONS_SHIM + content;
344 patched = true;
345 }
346
347 if (!content.includes('chrome.storage.session')) {
348 // Insert after permissions shim closing })();
349 const marker = '})();\n';
350 const idx = content.indexOf(marker);
351 if (idx !== -1) {
352 const insertAt = idx + marker.length;
353 content = content.slice(0, insertAt) + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM + content.slice(insertAt);
354 } else {
355 content = content + STORAGE_SESSION_SHIM + GET_BACKGROUND_PAGE_SHIM;
356 }
357 patched = true;
358 }
359
360 if (!patched) {
361 console.log('[patch] Proton Pass background.js already patched');
362 return;
363 }
364
365 fs.writeFileSync(bgPath, content);
366 console.log('[patch] Proton Pass background.js patched with shims');
367
368 // --- Patch polyfills.js (loaded by popup.html, settings.html before their entry scripts) ---
369 const polyfillsPath = path.join(extPath, 'polyfills.js');
370 if (fs.existsSync(polyfillsPath)) {
371 let polyContent = fs.readFileSync(polyfillsPath, 'utf-8');
372 if (polyContent.indexOf(SHIM_MARKER) === -1) {
373 polyContent = POLYFILLS_PERMISSIONS_SHIM + polyContent;
374 fs.writeFileSync(polyfillsPath, polyContent);
375 console.log('[patch] Proton Pass polyfills.js patched with permissions shim');
376 } else {
377 console.log('[patch] Proton Pass polyfills.js already patched');
378 }
379 } else {
380 console.warn('[patch] WARNING: Proton Pass polyfills.js not found');
381 }
382
383 // Verify required files exist
384 const required = ['polyfills.js', 'popup.html', 'settings.html'];
385 const missing = required.filter(f => !fs.existsSync(path.join(extPath, f)));
386 if (missing.length > 0) {
387 console.warn(`[patch] WARNING: Proton Pass missing files: ${missing.join(', ')}`);
388 console.warn('[patch] Re-extract the extension from CRX to get missing files.');
389 }
390}
391
392// Run patches
393console.log('[patch] Patching Chrome extensions for Electron compatibility...');
394patchProtonPass();
395console.log('[patch] Done');