experiments in a post-browser web
10
fork

Configure Feed

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

fix(chrome-ext): unbreak Proton Pass chrome.permissions / browser.permissions in popup

The popup showed "Permission denied for site access" even after the user
clicked grant. Two compounding causes:

1. The renderer-side permissions polyfill ran on `dom-ready`, after Proton's
bundled webextension-polyfill had already replaced `globalThis.chrome` with
a Proxy whose `get` trap filters out 'permissions'. Defining
`chrome.permissions` on that Proxy's target made the property visible to
`getOwnPropertyDescriptor` but not to plain reads — and worse, with
`configurable:false` on a property the trap couldn't return triggered the
"non-configurable data property must return target's value" Proxy invariant
error during popup load.

2. The verification log line dereferenced `chrome.permissions.request` after
the install, which threw inside the IIFE and skipped the `browser.permissions`
install entirely.

Fix:

- Switch the preload install to `configurable:true, writable:true` so
downstream Proxy wraps don't violate the get-trap invariant. Wrap the
verification log so a Proxy hiding `permissions` doesn't abort the IIFE.
- Add an early-install path via `scripts/patch-chrome-extensions.js`: write a
`peek-permissions.js` file into the extension dir and inject a
`<script src=...>` tag at the top of each HTML entry (popup, settings,
onboarding, internal, notification, dropdown). MV3 CSP blocks inline
scripts, but `script-src 'self'` allows the external file. The shim
patches `chrome.permissions` and then locks `globalThis.chrome` with
`writable:false, configurable:false` so webextension-polyfill can't
swap the binding for a Proxy that hides our shim.
- Drop the now-dead inline polyfills.js shim (chrome was undefined at that
script's first byte, so the prepend never reached its install code).

Adds `tests/desktop/proton-pass-permissions.spec.ts` asserting that after
popup load, `chrome.permissions` and `browser.permissions` both === the shim,
no page errors fire, and `request`/`contains` resolve to `true` for proton.me
origins.

+340 -217
+32 -63
--
··· 1 - feat(page): page-host FSM Phase 3 — fsmState is the source of truth 1 + fix(chrome-ext): unbreak Proton Pass chrome.permissions / browser.permissions in popup 2 2 3 - Phases 1 + 2 introduced the FSM and dispatched transitions at every 4 - entry/exit, but `isDragging` / `isResizing` / `isMaximized` were still 5 - the actual source of truth — the FSM was shadow state kept in sync via 6 - idempotent applyFsmEffect calls. Phase 3 flips that: fsmState is now the 7 - only place lifecycle state lives. 3 + The popup showed "Permission denied for site access" even after the user 4 + clicked grant. Two compounding causes: 8 5 9 - Reads → predicates: 10 - * inMaximized() ↔ fsmState === MAXIMIZED 11 - * inDragging() ↔ fsmState === DRAGGING || DRAGGING_OUT_OF_MAXIMIZED 12 - * inResizing() ↔ fsmState === RESIZING 6 + 1. The renderer-side permissions polyfill ran on `dom-ready`, after Proton's 7 + bundled webextension-polyfill had already replaced `globalThis.chrome` with 8 + a Proxy whose `get` trap filters out 'permissions'. Defining 9 + `chrome.permissions` on that Proxy's target made the property visible to 10 + `getOwnPropertyDescriptor` but not to plain reads — and worse, with 11 + `configurable:false` on a property the trap couldn't return triggered the 12 + "non-configurable data property must return target's value" Proxy invariant 13 + error during popup load. 13 14 14 - Every read of the legacy flags (16 isDragging, 5 isResizing, 17 15 - isMaximized sites) now goes through these helpers. The flag declarations 16 - and every inline `flag = true/false` write are deleted. 15 + 2. The verification log line dereferenced `chrome.permissions.request` after 16 + the install, which threw inside the IIFE and skipped the `browser.permissions` 17 + install entirely. 17 18 18 - applyFsmEffect cleanup: 19 - * SET_BODY_MAXIMIZED no longer writes `isMaximized = effect.value` — 20 - the variable doesn't exist anymore. The effect just toggles the 21 - body class; fsmState (already assigned by dispatchFsm before 22 - effects run) is authoritative. 23 - * Comments updated to reflect that effects translate state changes 24 - into idempotent DOM/URL updates, not the other way around. 19 + Fix: 25 20 26 - toggleMaximize restructure: 27 - * dispatchFsm(TOGGLE_MAXIMIZE) now runs BEFORE updatePositions / 28 - updateUrlParams / setBounds, so those see the new fsmState + 29 - updated screenBounds. Previously dispatch was at the end of each 30 - branch and inline `isMaximized = …` writes kept the shadow flag 31 - in sync; without those writes the dispatch must move forward so 32 - that `inMaximized()` reads inside updatePositions / updateUrlParams 33 - return the post-toggle value. 34 - * Both branches first mutate screenBounds + clear/capture pre-max 35 - bounds, then dispatch, then setBounds. The dispatch's effects 36 - (SET_BODY_MAXIMIZED, SET_HANDLES_VISIBLE→updatePositions, 37 - SET_URL_MAXIMIZED→updateUrlParams) now do the work the old inline 38 - code used to do. 21 + - Switch the preload install to `configurable:true, writable:true` so 22 + downstream Proxy wraps don't violate the get-trap invariant. Wrap the 23 + verification log so a Proxy hiding `permissions` doesn't abort the IIFE. 24 + - Add an early-install path via `scripts/patch-chrome-extensions.js`: write a 25 + `peek-permissions.js` file into the extension dir and inject a 26 + `<script src=...>` tag at the top of each HTML entry (popup, settings, 27 + onboarding, internal, notification, dropdown). MV3 CSP blocks inline 28 + scripts, but `script-src 'self'` allows the external file. The shim 29 + patches `chrome.permissions` and then locks `globalThis.chrome` with 30 + `writable:false, configurable:false` so webextension-polyfill can't 31 + swap the binding for a Proxy that hides our shim. 32 + - Drop the now-dead inline polyfills.js shim (chrome was undefined at that 33 + script's first byte, so the prepend never reached its install code). 39 34 40 - startDrag restructure: 41 - * Captures `wasMaximized = inMaximized()` BEFORE dispatchFsm 42 - (DRAG_START flips MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED, so a 43 - post-dispatch `inMaximized()` returns false). 44 - * Inline `document.body.classList.remove('maximized')` removed — 45 - the FSM's SET_BODY_MAXIMIZED(false) effect handles it. 46 - * Inline `isDragging = true` removed — DRAG_START transitions the 47 - FSM into DRAGGING. 48 - 49 - Drag-end paths (toggleMaximize cancel, webview mouseup, document 50 - mousemove orphan-cleanup, document mouseup) all now read inDragging() 51 - and rely on dispatchFsm(DRAG_END) for the state transition; inline 52 - `isDragging = false` writes deleted. 53 - 54 - Pre-maximize bounds (preMaximizeBounds, preMaximizeWindowBounds, 55 - dragOutOfMaximizeWindowSize) remain module-scoped — moving them into 56 - FSM context (the design doc's "small attribute bag") is deferred. They 57 - behave correctly today, and the migration would expand the FSM module 58 - shape (state → {state, context}) and unit-test surface beyond the scope 59 - of "delete the flags." 60 - 61 - Test results: 62 - * unit: 628/628 63 - * page-host-fsm: 5/5 64 - * session-restore-page-host: 2/2 65 - * reopen-closed-window: 5/5 66 - * page-load-failure: 5/5 67 - 68 - Tasks doc updated; Phase 3 entry marked done with the regression-suite 69 - roll call. 35 + Adds `tests/desktop/proton-pass-permissions.spec.ts` asserting that after 36 + popup load, `chrome.permissions` and `browser.permissions` both === the shim, 37 + no page errors fire, and `request`/`contains` resolve to `true` for proton.me 38 + origins.
+23 -13
backend/electron/chrome-api-polyfills/permissions.js
··· 51 51 */ 52 52 export function getPreloadScript() { 53 53 return ` 54 - /* chrome.permissions polyfill for Electron — completely replaces chrome.permissions. 55 - * Saves a reference to the real chrome object, creates a complete permissions object, 56 - * and installs it as non-configurable to prevent overwrites by native bindings or 57 - * extension code (e.g. Proton Pass Proxy replacement). No polling needed. */ 54 + /* chrome.permissions polyfill for Electron — replaces chrome.permissions with a 55 + * no-op shim that reports all permission requests as granted. configurable:true 56 + * so downstream Proxy wrappers don't violate the get-trap invariant. Some 57 + * extensions also need an early-install via patch-chrome-extensions.js to win 58 + * the race against bundled webextension-polyfill that replaces globalThis.chrome. */ 58 59 (function() { 59 60 if (typeof chrome === 'undefined') return; 60 61 ··· 116 117 globalThis.__peekPermissions = _permsObj; 117 118 118 119 /** 119 - * Install our permissions object on a root object, making it non-configurable. 120 + * Install our permissions object on a root object as configurable:true so 121 + * downstream Proxy wraps don't trigger the get-trap value invariant. 120 122 * Three attempts: defineProperty, delete+defineProperty, direct assignment. 121 123 */ 122 124 function installPermissions(root, name) { 123 125 if (!root || typeof root !== 'object') return false; 124 126 try { 125 - Object.defineProperty(root, name, { value: _permsObj, writable: false, configurable: false, enumerable: true }); 126 - _log('installed on', name, 'via defineProperty (non-configurable)'); 127 + Object.defineProperty(root, name, { value: _permsObj, writable: true, configurable: true, enumerable: true }); 128 + _log('installed on', name, 'via defineProperty'); 127 129 return true; 128 130 } catch(e) { _log('defineProperty failed on', name, ':', e.message); } 129 131 try { 130 132 delete root[name]; 131 - Object.defineProperty(root, name, { value: _permsObj, writable: false, configurable: false, enumerable: true }); 133 + Object.defineProperty(root, name, { value: _permsObj, writable: true, configurable: true, enumerable: true }); 132 134 _log('installed on', name, 'via delete+defineProperty'); 133 135 return true; 134 136 } catch(e) { _log('delete+defineProperty failed on', name, ':', e.message); } ··· 141 143 } 142 144 143 145 var installed = installPermissions(_chrome, 'permissions'); 144 - _log('shim installed on chrome.permissions:', installed, 'chrome.permissions.request:', typeof _chrome.permissions.request); 146 + try { 147 + var requestType = _chrome.permissions && typeof _chrome.permissions.request; 148 + _log('shim installed on chrome.permissions:', installed, 'chrome.permissions.request:', requestType); 149 + } catch(e) { 150 + _log('shim installed on chrome.permissions:', installed, '(verify access threw — likely Proxy hides it):', e.message); 151 + } 145 152 146 - // Also patch browser.permissions if browser object exists 147 - if (typeof browser !== 'undefined' && browser) { 148 - installPermissions(browser, 'permissions'); 149 - } 153 + // Also patch browser.permissions if browser object exists. 154 + // Wrap in try/catch — browser may itself be a Proxy that hides permissions. 155 + try { 156 + if (typeof browser !== 'undefined' && browser) { 157 + installPermissions(browser, 'permissions'); 158 + } 159 + } catch(e) { _log('browser.permissions install threw:', e.message); } 150 160 })(); 151 161 `; 152 162 }
+193 -141
scripts/patch-chrome-extensions.js
··· 27 27 const EXT_DIR = path.join(ROOT, 'chrome-extensions'); 28 28 29 29 const SHIM_MARKER = '/* Peek: chrome.permissions'; 30 + const HTML_SHIM_MARKER = '<!--peek:permissions-->'; 30 31 31 32 /** 32 33 * The permissions shim. Electron does not implement chrome.permissions. ··· 37 38 * Chrome callback convention). Without this, the 38 39 * service worker crashes at startup on permissions.onAdded.addListener(). 39 40 */ 40 - const 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. */ 41 + const PERMISSIONS_SHIM = `/* Peek: chrome.permissions shim for Electron compatibility. 42 + * Strategy: replace globalThis.chrome with a Proxy whose target is a plain 43 + * empty object {}. The Proxy's get trap returns our shim for 'permissions' 44 + * and delegates everything else to the real native chrome. 45 + * 46 + * Why this shape: 47 + * 1. Electron's native chrome.permissions is a non-configurable, non-writable 48 + * property on the native chrome object. We can't redefine it via 49 + * defineProperty, and even mutating its methods doesn't help once a 50 + * downstream consumer (e.g. Proton Pass) wraps chrome in their own Proxy 51 + * that hides 'permissions' — the V8 invariant fires because the target 52 + * property is non-configurable but the consumer's get trap returns 53 + * undefined. 54 + * 2. Replacing chrome with a Proxy whose target is {} means the target has 55 + * NO own properties — no invariants apply to anything. Downstream Proxies 56 + * can wrap us freely without tripping V8's checks. 57 + * 3. The replacement uses configurable:true / writable:true so other code 58 + * (Proton's polyfill) can still wrap us in their own Proxy. */ 45 59 (function() { 46 60 var DEBUG_PERMISSIONS = false; 47 61 function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions]'].concat(Array.prototype.slice.call(arguments))); } ··· 52 66 NoopEvent.prototype.hasListener = function(fn) { return this._l.indexOf(fn) !== -1; }; 53 67 NoopEvent.prototype.hasListeners = function() { return this._l.length > 0; }; 54 68 55 - /* Save reference to the REAL chrome object before anything can replace it */ 56 69 var _chrome = (typeof chrome !== 'undefined') ? chrome : null; 57 70 if (!_chrome) return; 58 71 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() 72 + /* The polyfill methods that always grant. They reference _chrome (via runtime.getManifest) */ 73 + function _request(perms, callback) { 74 + _log('request called with:', JSON.stringify(perms)); 75 + try { delete _chrome.runtime.lastError; } catch(e) {} 76 + if (typeof callback === 'function') callback(true); 77 + return Promise.resolve(true); 78 + } 79 + function _contains(perms, callback) { 80 + _log('contains called with:', JSON.stringify(perms)); 81 + try { delete _chrome.runtime.lastError; } catch(e) {} 82 + if (typeof callback === 'function') callback(true); 83 + return Promise.resolve(true); 84 + } 85 + function _getAll(callback) { 86 + var result = { permissions: [], origins: [] }; 87 + try { 88 + if (_chrome.runtime && _chrome.runtime.getManifest) { 89 + var m = _chrome.runtime.getManifest(); 90 + result = { permissions: m.permissions || [], origins: m.host_permissions || [] }; 91 + } 92 + } catch(e) {} 93 + try { delete _chrome.runtime.lastError; } catch(e) {} 94 + if (typeof callback === 'function') callback(result); 95 + return Promise.resolve(result); 96 + } 97 + function _remove(perms, callback) { 98 + try { delete _chrome.runtime.lastError; } catch(e) {} 99 + if (typeof callback === 'function') callback(true); 100 + return Promise.resolve(true); 101 + } 102 + 103 + var _shimPerms = { 104 + request: _request, contains: _contains, getAll: _getAll, remove: _remove, 105 + onAdded: new NoopEvent(), onRemoved: new NoopEvent(), 97 106 }; 98 107 99 - /* Save backup ref so we can verify our shim is being used */ 100 - globalThis.__peekPermissions = _permsObj; 108 + /* Backup object — accessible from tests to verify the shim is loaded. */ 109 + globalThis.__peekPermissions = _shimPerms; 101 110 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; 111 + /* Build a Proxy wrapper for chrome with {} as target. */ 112 + function wrapWithPermissionsShim(nativeObj, shimPerms) { 113 + if (!nativeObj || typeof nativeObj !== 'object') return nativeObj; 114 + return new Proxy({}, { 115 + get: function(_target, prop) { 116 + if (prop === 'permissions') return shimPerms; 117 + var val = nativeObj[prop]; 118 + if (typeof val === 'function') return val.bind(nativeObj); 119 + return val; 120 + }, 121 + set: function(_target, prop, value) { 122 + if (prop === 'permissions') return true; /* swallow attempts to overwrite our shim */ 123 + try { nativeObj[prop] = value; } catch(e) {} 124 + return true; 125 + }, 126 + has: function(_target, prop) { 127 + return prop === 'permissions' || prop in nativeObj; 128 + }, 129 + ownKeys: function(_target) { 130 + var keys = []; 131 + try { keys = Reflect.ownKeys(nativeObj); } catch(e) {} 132 + if (keys.indexOf('permissions') === -1) keys.push('permissions'); 133 + return keys; 134 + }, 135 + getOwnPropertyDescriptor: function(_target, prop) { 136 + if (prop === 'permissions') { 137 + return { value: shimPerms, writable: true, configurable: true, enumerable: true }; 138 + } 139 + try { return Object.getOwnPropertyDescriptor(nativeObj, prop); } catch(e) { return undefined; } 140 + }, 141 + }); 127 142 } 128 143 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 - } 144 + try { 145 + Object.defineProperty(globalThis, 'chrome', { 146 + value: wrapWithPermissionsShim(_chrome, _shimPerms), 147 + writable: true, configurable: true, enumerable: true, 148 + }); 149 + _log('chrome wrapped with permissions Proxy'); 150 + } catch(e) { _log('chrome wrap failed:', e.message); } 135 151 })(); 136 152 `; 137 153 138 154 /** 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. 155 + * Polyfills-only permissions shim. Same method-mutation strategy as the 156 + * background shim. Method mutation does not interact with browser.permissions 157 + * (which webextension-polyfill builds during popup.js execution) so the old 158 + * "black screen on early browser.permissions touch" hazard doesn't apply. 144 159 */ 145 - const 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. */ 160 + /** 161 + * Standalone shim file — written as `peek-permissions.js` into the extension 162 + * directory and referenced from each HTML entry point via <script src=...> 163 + * BEFORE polyfills.js. Inline scripts are blocked by the extension's 164 + * Manifest-V3 CSP (script-src 'self'), but external 'self' scripts are 165 + * allowed. Installs a setter on globalThis.chrome so we patch in 166 + * chrome.permissions the moment Electron defines chrome — before any 167 + * extension script can read it or wrap it in a Proxy. 168 + */ 169 + const PEEK_PERMISSIONS_JS = `/* Peek: chrome.permissions early-install shim. See patch-chrome-extensions.js. */ 150 170 (function() { 151 171 var DEBUG_PERMISSIONS = false; 152 - function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions:polyfills]'].concat(Array.prototype.slice.call(arguments))); } 172 + function _log() { if (DEBUG_PERMISSIONS) console.log.apply(console, ['[peek:permissions:html]'].concat(Array.prototype.slice.call(arguments))); } 153 173 154 174 function NoopEvent() { this._l = []; } 155 175 NoopEvent.prototype.addListener = function(fn) { this._l.push(fn); }; ··· 157 177 NoopEvent.prototype.hasListener = function(fn) { return this._l.indexOf(fn) !== -1; }; 158 178 NoopEvent.prototype.hasListeners = function() { return this._l.length > 0; }; 159 179 160 - var _chrome = (typeof chrome !== 'undefined') ? chrome : null; 161 - if (!_chrome) return; 180 + function _clearLastError(c) { try { delete c.runtime.lastError; } catch(e) {} } 181 + 182 + var _real; 183 + function _getChrome() { return _real; } 162 184 163 185 var _permsObj = { 186 + request: function(perms, callback) { 187 + _log('request:', JSON.stringify(perms)); 188 + _clearLastError(_getChrome()); 189 + if (typeof callback === 'function') callback(true); 190 + return Promise.resolve(true); 191 + }, 164 192 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'); 193 + _log('contains:', JSON.stringify(perms)); 194 + _clearLastError(_getChrome()); 168 195 if (typeof callback === 'function') callback(true); 169 196 return Promise.resolve(true); 170 197 }, 171 198 getAll: function(callback) { 172 - _log('getAll called'); 199 + var c = _getChrome(); 173 200 var result = { permissions: [], origins: [] }; 174 201 try { 175 - if (_chrome.runtime && _chrome.runtime.getManifest) { 176 - var m = _chrome.runtime.getManifest(); 202 + if (c && c.runtime && c.runtime.getManifest) { 203 + var m = c.runtime.getManifest(); 177 204 result = { permissions: m.permissions || [], origins: m.host_permissions || [] }; 178 205 } 179 206 } catch(e) {} 180 - try { delete _chrome.runtime.lastError; } catch(e) {} 181 - _log('getAll returning:', JSON.stringify(result)); 207 + _clearLastError(c); 182 208 if (typeof callback === 'function') callback(result); 183 209 return Promise.resolve(result); 184 210 }, 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 211 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'); 212 + _clearLastError(_getChrome()); 196 213 if (typeof callback === 'function') callback(true); 197 214 return Promise.resolve(true); 198 215 }, 199 216 onAdded: new NoopEvent(), 200 - onRemoved: new NoopEvent() 217 + onRemoved: new NoopEvent(), 201 218 }; 202 - 203 219 globalThis.__peekPermissions = _permsObj; 204 220 205 - function installPermissions(root, name) { 206 - if (!root || typeof root !== 'object') return false; 221 + function _patchChrome(c) { 222 + if (!c || typeof c !== 'object') return; 207 223 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); } 224 + Object.defineProperty(c, 'permissions', { value: _permsObj, writable: true, configurable: true, enumerable: true }); 225 + _log('installed chrome.permissions on captured chrome'); 226 + } catch(e) { 227 + try { c.permissions = _permsObj; _log('installed via assignment'); } 228 + catch(e2) { _log('install failed:', e.message, '/', e2.message); } 229 + } 230 + } 231 + 232 + if (typeof chrome !== 'undefined' && chrome) { 233 + _real = chrome; 234 + _patchChrome(_real); 235 + // Lock globalThis.chrome so downstream code (e.g. webextension-polyfill) 236 + // can't replace the binding with a Proxy that hides permissions. The 237 + // underlying object is the same; only the binding is frozen. 212 238 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; 239 + Object.defineProperty(globalThis, 'chrome', { value: _real, writable: false, configurable: false, enumerable: true }); 240 + _log('locked globalThis.chrome'); 241 + } catch(e) { _log('lock globalThis.chrome failed:', e.message); } 242 + return; 224 243 } 225 244 226 - var installed = installPermissions(_chrome, 'permissions'); 227 - _log('shim installed:', installed, 'chrome.permissions.request:', typeof _chrome.permissions.request); 245 + // chrome not yet defined. Install setter to capture Electron's assignment. 246 + try { 247 + Object.defineProperty(globalThis, 'chrome', { 248 + configurable: true, 249 + enumerable: true, 250 + get: function() { return _real; }, 251 + set: function(v) { 252 + _real = v; 253 + _patchChrome(_real); 254 + }, 255 + }); 256 + _log('chrome setter installed'); 257 + } catch(e) { _log('chrome setter install failed:', e.message); } 228 258 })(); 229 259 `; 260 + const PEEK_PERMISSIONS_SCRIPT_TAG = '<!--peek:permissions--><script src="peek-permissions.js" charset="UTF-8"></script>'; 230 261 231 262 /** 232 263 * chrome.storage.session in-memory shim. Electron does not provide ··· 365 396 fs.writeFileSync(bgPath, content); 366 397 console.log('[patch] Proton Pass background.js patched with shims'); 367 398 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'); 399 + // --- Write peek-permissions.js into the extension directory --- 400 + // Manifest-V3 CSP blocks inline <script>, so we ship the shim as a real file 401 + // referenced via <script src> from each HTML entry. CSP allows 'self'. 402 + const peekJsPath = path.join(extPath, 'peek-permissions.js'); 403 + fs.writeFileSync(peekJsPath, PEEK_PERMISSIONS_JS); 404 + console.log('[patch] Wrote peek-permissions.js'); 405 + 406 + // --- Patch HTML entry points (popup.html, settings.html, etc.) --- 407 + // Inject <script src="peek-permissions.js"> right after <body>, BEFORE 408 + // polyfills.js / popup.js script tags. Our shim runs first, installs a 409 + // setter on globalThis.chrome, and patches in chrome.permissions the moment 410 + // Electron defines chrome — before any extension script reads it or wraps 411 + // it in a Proxy. 412 + const HTML_FILES = [ 413 + 'popup.html', 'settings.html', 'onboarding.html', 414 + 'internal.html', 'notification.html', 'dropdown.html', 415 + ]; 416 + for (const htmlFile of HTML_FILES) { 417 + const htmlPath = path.join(extPath, htmlFile); 418 + if (!fs.existsSync(htmlPath)) continue; 419 + let html = fs.readFileSync(htmlPath, 'utf-8'); 420 + if (html.includes(HTML_SHIM_MARKER)) { 421 + console.log(`[patch] Proton Pass ${htmlFile} already patched`); 422 + continue; 378 423 } 379 - } else { 380 - console.warn('[patch] WARNING: Proton Pass polyfills.js not found'); 424 + const m = html.match(/<body[^>]*>/i); 425 + if (!m) { 426 + console.warn(`[patch] WARNING: ${htmlFile} has no <body> tag`); 427 + continue; 428 + } 429 + const insertAt = m.index + m[0].length; 430 + html = html.slice(0, insertAt) + '\n' + PEEK_PERMISSIONS_SCRIPT_TAG + '\n' + html.slice(insertAt); 431 + fs.writeFileSync(htmlPath, html); 432 + console.log(`[patch] Proton Pass ${htmlFile} patched with peek-permissions script tag`); 381 433 } 382 434 383 435 // Verify required files exist
+92
tests/desktop/proton-pass-permissions.spec.ts
··· 1 + /** 2 + * Regression spec for Proton Pass permissions in popup. 3 + * 4 + * Original bug: Proton Pass popup showed "Permission denied for site access" 5 + * after the user clicked the in-extension grant button, because Electron's 6 + * native chrome.permissions has no working request() and downstream Proxy 7 + * wraps in webextension-polyfill hid our shim from chrome.permissions / 8 + * browser.permissions. 9 + * 10 + * Fix: scripts/patch-chrome-extensions.js writes peek-permissions.js into the 11 + * extension dir and references it as the FIRST script tag in each HTML entry 12 + * (before polyfills.js). The shim patches chrome.permissions, then locks 13 + * globalThis.chrome with writable:false so downstream code can't replace 14 + * the binding with a Proxy that filters out 'permissions'. 15 + * 16 + * This spec asserts: 17 + * 1. No page errors occur during popup load. 18 + * 2. chrome.permissions and browser.permissions are both our shim. 19 + * 3. request() and contains() return true for proton.me origins. 20 + */ 21 + 22 + import { test, expect } from '../fixtures/desktop-app'; 23 + import { Page } from '@playwright/test'; 24 + import { createPerDescribeApp } from '../helpers/test-app'; 25 + 26 + test.describe('Proton Pass popup permissions @desktop', () => { 27 + let app: any; 28 + let bgWindow: Page; 29 + 30 + test.beforeAll(async () => { 31 + ({ app, bgWindow } = await createPerDescribeApp('proton-pass-perms')); 32 + }); 33 + 34 + test.afterAll(async () => { 35 + if (app) await app.close(); 36 + }); 37 + 38 + test('chrome.permissions and browser.permissions in popup', async () => { 39 + // Resolve the Proton Pass popup URL via the canonical UI-entries IPC. 40 + const entries = await bgWindow.evaluate(async () => { 41 + const r = await (window as any).app.chromeExtensions.getUiEntries(); 42 + return r.data as Array<{ extensionId: string; extensionName: string; type: string; url: string }>; 43 + }); 44 + const popupEntry = entries.find( 45 + (e) => e.type === 'popup' && e.extensionName.toLowerCase().includes('proton'), 46 + ); 47 + expect(popupEntry, 'Proton Pass popup entry must exist').toBeTruthy(); 48 + 49 + const openResult = await bgWindow.evaluate(async (url: string) => { 50 + return await (window as any).app.window.open(url, { 51 + width: 400, height: 500, key: 'proton-popup-perms', 52 + }); 53 + }, popupEntry!.url); 54 + expect(openResult.success).toBe(true); 55 + 56 + const popup = await app.getWindow('popup.html', 15000); 57 + expect(popup).toBeTruthy(); 58 + 59 + const pageErrors: string[] = []; 60 + popup.on('pageerror', (err) => pageErrors.push(err.message)); 61 + 62 + await popup.waitForLoadState('domcontentloaded'); 63 + // browser.permissions is exposed by webextension-polyfill (in popup.js 64 + // defer) wrapping our chrome.permissions. Wait for it as the readiness signal. 65 + await popup.waitForFunction( 66 + () => typeof (globalThis as any).browser?.permissions?.request === 'function', 67 + { timeout: 5000 }, 68 + ); 69 + 70 + const result = await popup.evaluate(async () => { 71 + const c = (globalThis as any).chrome; 72 + const b = (globalThis as any).browser; 73 + const peek = (globalThis as any).__peekPermissions; 74 + return { 75 + chromePermsIsShim: c.permissions === peek, 76 + browserPermsIsShim: b.permissions === peek, 77 + chromeRequest: await c.permissions.request({ origins: ['https://account.proton.me/*', 'https://pass.proton.me/*'] }), 78 + chromeContains: await c.permissions.contains({ origins: ['https://account.proton.me/*'] }), 79 + browserRequest: await b.permissions.request({ origins: ['https://account.proton.me/*', 'https://pass.proton.me/*'] }), 80 + browserContains: await b.permissions.contains({ origins: ['https://account.proton.me/*'] }), 81 + }; 82 + }); 83 + 84 + expect(pageErrors, 'no page errors during popup load').toEqual([]); 85 + expect(result.chromePermsIsShim, 'chrome.permissions === __peekPermissions').toBe(true); 86 + expect(result.browserPermsIsShim, 'browser.permissions === __peekPermissions').toBe(true); 87 + expect(result.chromeRequest).toBe(true); 88 + expect(result.chromeContains).toBe(true); 89 + expect(result.browserRequest).toBe(true); 90 + expect(result.browserContains).toBe(true); 91 + }); 92 + });