experiments in a post-browser web
at main 395 lines 16 kB view raw
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');