experiments in a post-browser web
10
fork

Configure Feed

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

fix(settings): re-render Extensions section once chrome extensions finish loading

Settings → Extensions stopped showing per-extension Settings/Popup
buttons (third regression — previously fixed 2026-04-23 and
2026-04-17).

Root cause is a load-order race: renderPrivacySettings() runs once at
Settings page load, but loadEnabledChromeExtensions() in
backend/electron/entry.ts is async and fire-and-forget. If the
Settings window opens before extensions finish loading, the
loadedExtensions Map is empty, getChromeExtensionUiEntries() returns
[], and no buttons render. The Features pane already had a
feature:all-loaded refresh signal; the Extensions pane had nothing
equivalent.

Fix:

- backend/electron/entry.ts publishes a chrome-extensions:loaded
pubsub event after loadEnabledChromeExtensions() completes.
- app/settings/settings.js subscribes once, clears the Privacy section
contents, and re-appends the freshly-rendered tree on receipt.

Refactor for testability:

- Extracted computeUiEntries() into chrome-extensions-helpers.ts as a
pure dependency-injected function (UiEntryManifest /
UiEntryDiscovered / UiEntryLoaded / ComputedUiEntry types). The
Electron-coupled getChromeExtensionUiEntries() in
chrome-extensions.ts delegates to it.
- New chrome-extensions-ui-entries.test.ts: 12 unit tests across
empty-state, MV3 (action + options_ui), MV2 (browser_action +
options_page), and extensionId key-matching. The key regression
guard asserts exactly 2 openable entries for the proton-pass
fixture — if this drops to 0, Settings → Extensions buttons are
missing again, and the test fails immediately instead of after a
release.

+336 -92
+17
app/settings/settings.js
··· 3439 3439 3440 3440 contentArea.appendChild(privacySection); 3441 3441 3442 + // Re-render the Extensions section when chrome extensions finish loading. 3443 + // loadEnabledChromeExtensions() is async and fires without await at startup, 3444 + // so a settings window opened before it completes would see empty 3445 + // getChromeExtensionUiEntries() → no popup/options buttons. The 3446 + // chrome-extensions:loaded event (published by entry.ts after the async 3447 + // load completes) triggers a full re-render of the section content so 3448 + // the buttons always appear regardless of open-vs-load ordering. 3449 + api.subscribe('chrome-extensions:loaded', async () => { 3450 + // Clear and re-render privacy section content. 3451 + while (privacySection.childNodes.length > 1) { 3452 + // Keep the first child (the h2 title); remove everything else. 3453 + privacySection.removeChild(privacySection.lastChild); 3454 + } 3455 + const refreshed = await renderPrivacySettings(); 3456 + privacySection.appendChild(refreshed); 3457 + }); 3458 + 3442 3459 // Add Permissions section (between Extensions and Dark Mode). 3443 3460 // Lists per-origin web permission decisions persisted by the page-host 3444 3461 // permission handler (geolocation, camera/mic, etc.) and lets the user
+149 -1
backend/electron/chrome-extensions-helpers.ts
··· 1 1 /** 2 - * Pure helpers for chrome-extension command registration. 2 + * Pure helpers for chrome-extension command registration and UI entry computation. 3 3 * 4 4 * Kept in a separate file (no electron imports) so they can be unit-tested 5 5 * under plain node without needing the Electron runtime. 6 6 */ 7 + 8 + // ─── Types (duplicated from chrome-extensions.ts to avoid cross-import) ────── 9 + // Kept minimal — only the manifest fields actually read by computeUiEntries. 10 + 11 + export interface UiEntryManifest { 12 + action?: { default_popup?: string; default_title?: string }; 13 + browser_action?: { default_popup?: string; default_title?: string }; 14 + options_ui?: { page: string }; 15 + options_page?: string; 16 + background?: { page?: string; service_worker?: string; scripts?: string[] }; 17 + chrome_url_overrides?: { newtab?: string; history?: string; bookmarks?: string }; 18 + devtools_page?: string; 19 + side_panel?: { default_path?: string }; 20 + sidebar_action?: { default_panel?: string; default_title?: string }; 21 + } 22 + 23 + export interface UiEntryDiscovered { 24 + name: string; 25 + manifest: UiEntryManifest; 26 + } 27 + 28 + export interface UiEntryLoaded { 29 + extensionUrl: string; // chrome-extension://[id]/ base URL 30 + } 31 + 32 + export interface ComputedUiEntry { 33 + extensionId: string; 34 + extensionName: string; 35 + type: 'popup' | 'options' | 'background' | 'newtab' | 'history' | 'bookmarks' | 'devtools' | 'sidepanel'; 36 + title: string; 37 + url: string; 38 + width: number; 39 + height: number; 40 + openable: boolean; 41 + } 42 + 43 + // ─── Pure computation ───────────────────────────────────────────────────────── 44 + 45 + /** 46 + * Compute UI entry points for a set of loaded chrome extensions. 47 + * 48 + * Pure function — no module state. Takes plain maps so it can be called from 49 + * tests without an Electron session. `chrome-extensions.ts` calls this with 50 + * its module-level `loadedExtensions` / `discoveredExtensions` maps after 51 + * adapting the types. 52 + * 53 + * Returns entries only for extensions present in both `loaded` AND `discovered`. 54 + * An extension must be loaded (session.loadExtension succeeded) to contribute 55 + * entries — "enabled in DB but not yet loaded" contributes nothing, so the 56 + * caller must ensure loading is complete before rendering. 57 + */ 58 + export function computeUiEntries( 59 + loaded: Map<string, UiEntryLoaded>, 60 + discovered: Map<string, UiEntryDiscovered>, 61 + ): ComputedUiEntry[] { 62 + const entries: ComputedUiEntry[] = []; 63 + 64 + for (const [extId, loadedExt] of loaded) { 65 + const disc = discovered.get(extId); 66 + if (!disc) continue; 67 + 68 + const manifest = disc.manifest; 69 + const baseUrl = loadedExt.extensionUrl; 70 + 71 + // Action popup (action takes priority over browser_action for MV3) 72 + const popupPage = manifest.action?.default_popup || manifest.browser_action?.default_popup; 73 + if (popupPage) { 74 + entries.push({ 75 + extensionId: extId, extensionName: disc.name, type: 'popup', 76 + title: manifest.action?.default_title || manifest.browser_action?.default_title || disc.name, 77 + url: `${baseUrl}${popupPage}`, width: 400, height: 500, openable: true, 78 + }); 79 + } 80 + 81 + // Options page (options_ui takes priority over options_page) 82 + const optionsPage = manifest.options_ui?.page || manifest.options_page; 83 + if (optionsPage) { 84 + entries.push({ 85 + extensionId: extId, extensionName: disc.name, type: 'options', 86 + title: `${disc.name} Settings`, 87 + url: `${baseUrl}${optionsPage}`, width: 1024, height: 768, openable: true, 88 + }); 89 + } 90 + 91 + // Background page (service workers are not directly openable) 92 + const bgPage = manifest.background?.page; 93 + const bgWorker = manifest.background?.service_worker; 94 + const bgScripts = manifest.background?.scripts; 95 + if (bgPage || bgWorker || bgScripts) { 96 + const bgPath = bgPage || bgWorker || (bgScripts && bgScripts[0]) || ''; 97 + entries.push({ 98 + extensionId: extId, extensionName: disc.name, type: 'background', 99 + title: `${disc.name} Background`, 100 + url: bgPath ? `${baseUrl}${bgPath}` : '', 101 + width: 0, height: 0, openable: !!bgPage, 102 + }); 103 + } 104 + 105 + // Chrome URL overrides 106 + if (manifest.chrome_url_overrides) { 107 + const overrides = manifest.chrome_url_overrides; 108 + if (overrides.newtab) { 109 + entries.push({ 110 + extensionId: extId, extensionName: disc.name, type: 'newtab', 111 + title: `${disc.name} New Tab`, 112 + url: `${baseUrl}${overrides.newtab}`, width: 1024, height: 768, openable: true, 113 + }); 114 + } 115 + if (overrides.history) { 116 + entries.push({ 117 + extensionId: extId, extensionName: disc.name, type: 'history', 118 + title: `${disc.name} History`, 119 + url: `${baseUrl}${overrides.history}`, width: 1024, height: 768, openable: true, 120 + }); 121 + } 122 + if (overrides.bookmarks) { 123 + entries.push({ 124 + extensionId: extId, extensionName: disc.name, type: 'bookmarks', 125 + title: `${disc.name} Bookmarks`, 126 + url: `${baseUrl}${overrides.bookmarks}`, width: 1024, height: 768, openable: true, 127 + }); 128 + } 129 + } 130 + 131 + // DevTools page 132 + if (manifest.devtools_page) { 133 + entries.push({ 134 + extensionId: extId, extensionName: disc.name, type: 'devtools', 135 + title: `${disc.name} DevTools`, 136 + url: `${baseUrl}${manifest.devtools_page}`, width: 1024, height: 768, openable: true, 137 + }); 138 + } 139 + 140 + // Side panel 141 + const sidePanelPath = manifest.side_panel?.default_path || manifest.sidebar_action?.default_panel; 142 + if (sidePanelPath) { 143 + entries.push({ 144 + extensionId: extId, extensionName: disc.name, type: 'sidepanel', 145 + title: manifest.sidebar_action?.default_title || `${disc.name} Side Panel`, 146 + url: `${baseUrl}${sidePanelPath}`, width: 400, height: 768, openable: true, 147 + }); 148 + } 149 + } 150 + 151 + return entries; 152 + } 153 + 154 + // ─── Command name helpers ───────────────────────────────────────────────────── 7 155 8 156 /** 9 157 * Sanitize a chrome extension display name for use in a command name.
+153
backend/electron/chrome-extensions-ui-entries.test.ts
··· 1 + /** 2 + * Standing regression test for computeUiEntries — the pure helper that 3 + * produces Settings → Extensions popup/options buttons. 4 + * 5 + * This function has regressed THREE times (2026-04-17, 2026-04-23, 6 + * 2026-04-29) because it lives inside chrome-extensions.ts which depends on 7 + * module-level Maps that only populate after loadEnabledChromeExtensions() 8 + * completes. Extracting it as a pure helper lets us assert the logic without 9 + * an Electron session. 10 + * 11 + * Key invariant: a loaded extension with action.default_popup AND options_ui 12 + * MUST produce exactly two openable entries (popup + options) plus any 13 + * non-openable background entries. If this test fails, the buttons are 14 + * missing from Settings → Extensions. 15 + */ 16 + 17 + import { describe, it } from 'node:test'; 18 + import * as assert from 'node:assert'; 19 + 20 + import { 21 + computeUiEntries, 22 + type UiEntryLoaded, 23 + type UiEntryDiscovered, 24 + } from './chrome-extensions-helpers.js'; 25 + 26 + // ── Fixture: proton-pass-like manifest (MV3) ──────────────────────────────── 27 + 28 + const PROTON_PASS_DISCOVERED: UiEntryDiscovered = { 29 + name: 'Proton Pass', 30 + manifest: { 31 + action: { default_popup: 'popup.html', default_title: 'Proton Pass' }, 32 + options_ui: { page: 'settings.html' }, 33 + background: { service_worker: 'background.js' }, 34 + }, 35 + }; 36 + 37 + const PROTON_PASS_LOADED: UiEntryLoaded = { 38 + extensionUrl: 'chrome-extension://abc123hash/', 39 + }; 40 + 41 + // ── Fixture: MV2 extension with browser_action ────────────────────────────── 42 + 43 + const MV2_EXT_DISCOVERED: UiEntryDiscovered = { 44 + name: 'MV2 AdBlock', 45 + manifest: { 46 + browser_action: { default_popup: 'popup.html' }, 47 + options_page: 'options.html', 48 + background: { page: 'background.html' }, 49 + }, 50 + }; 51 + 52 + const MV2_EXT_LOADED: UiEntryLoaded = { 53 + extensionUrl: 'chrome-extension://mv2hash/', 54 + }; 55 + 56 + // ── Tests ─────────────────────────────────────────────────────────────────── 57 + 58 + describe('computeUiEntries — empty state', () => { 59 + it('returns [] when loadedExtensions is empty', () => { 60 + const result = computeUiEntries(new Map(), new Map()); 61 + assert.deepStrictEqual(result, []); 62 + }); 63 + 64 + it('returns [] when extension is discovered but not loaded', () => { 65 + const discovered = new Map([['proton-pass', PROTON_PASS_DISCOVERED]]); 66 + const result = computeUiEntries(new Map(), discovered); 67 + assert.deepStrictEqual(result, []); 68 + }); 69 + 70 + it('returns [] when extension is loaded but not discovered', () => { 71 + const loaded = new Map([['proton-pass', PROTON_PASS_LOADED]]); 72 + const result = computeUiEntries(loaded, new Map()); 73 + assert.deepStrictEqual(result, []); 74 + }); 75 + }); 76 + 77 + describe('computeUiEntries — proton-pass fixture (MV3 with action + options_ui)', () => { 78 + const loaded = new Map([['proton-pass', PROTON_PASS_LOADED]]); 79 + const discovered = new Map([['proton-pass', PROTON_PASS_DISCOVERED]]); 80 + const result = computeUiEntries(loaded, discovered); 81 + 82 + it('produces exactly 3 entries (popup + options + background)', () => { 83 + assert.strictEqual(result.length, 3); 84 + }); 85 + 86 + it('popup entry is openable and has correct extensionId', () => { 87 + const popup = result.find(e => e.type === 'popup'); 88 + assert.ok(popup, 'popup entry must exist'); 89 + assert.strictEqual(popup.extensionId, 'proton-pass'); 90 + assert.strictEqual(popup.openable, true); 91 + assert.strictEqual(popup.url, 'chrome-extension://abc123hash/popup.html'); 92 + }); 93 + 94 + it('options entry is openable and has correct URL', () => { 95 + const options = result.find(e => e.type === 'options'); 96 + assert.ok(options, 'options entry must exist'); 97 + assert.strictEqual(options.extensionId, 'proton-pass'); 98 + assert.strictEqual(options.openable, true); 99 + assert.strictEqual(options.url, 'chrome-extension://abc123hash/settings.html'); 100 + }); 101 + 102 + it('background (service worker) entry is NOT openable', () => { 103 + const bg = result.find(e => e.type === 'background'); 104 + assert.ok(bg, 'background entry must exist'); 105 + assert.strictEqual(bg.openable, false); 106 + }); 107 + 108 + it('exactly 2 openable entries (the ones that produce buttons in Settings → Extensions)', () => { 109 + const openable = result.filter(e => e.openable); 110 + assert.strictEqual(openable.length, 2, 111 + 'Regression guard: Settings → Extensions shows one button per openable entry. ' + 112 + 'If this count drops to 0, the buttons are missing.'); 113 + }); 114 + }); 115 + 116 + describe('computeUiEntries — MV2 extension (browser_action + options_page + background page)', () => { 117 + const loaded = new Map([['mv2-adblock', MV2_EXT_LOADED]]); 118 + const discovered = new Map([['mv2-adblock', MV2_EXT_DISCOVERED]]); 119 + const result = computeUiEntries(loaded, discovered); 120 + 121 + it('produces popup from browser_action.default_popup', () => { 122 + const popup = result.find(e => e.type === 'popup'); 123 + assert.ok(popup, 'popup entry must exist for MV2 extension'); 124 + assert.strictEqual(popup.url, 'chrome-extension://mv2hash/popup.html'); 125 + }); 126 + 127 + it('produces options from options_page fallback', () => { 128 + const options = result.find(e => e.type === 'options'); 129 + assert.ok(options, 'options entry must exist from options_page'); 130 + assert.strictEqual(options.url, 'chrome-extension://mv2hash/options.html'); 131 + }); 132 + 133 + it('background page IS openable (unlike service workers)', () => { 134 + const bg = result.find(e => e.type === 'background'); 135 + assert.ok(bg, 'background entry must exist'); 136 + assert.strictEqual(bg.openable, true); 137 + }); 138 + }); 139 + 140 + describe('computeUiEntries — extensionId matching (Settings renderer uses ext.id === e.extensionId)', () => { 141 + it('extensionId in entries equals the Map key (directory name)', () => { 142 + const loaded = new Map([['consent-o-matic', { extensionUrl: 'chrome-extension://xhash/' }]]); 143 + const discovered = new Map([['consent-o-matic', { 144 + name: 'Consent-O-Matic', 145 + manifest: { action: { default_popup: 'popup.html' } }, 146 + }]]); 147 + const result = computeUiEntries(loaded, discovered); 148 + assert.strictEqual(result.length, 1); 149 + assert.strictEqual(result[0].extensionId, 'consent-o-matic', 150 + 'extensionId must match the directory-name key used by getChromeExtensions() ' + 151 + 'so the filter e.extensionId === ext.id in settings.js finds the entries'); 152 + }); 153 + });
+9 -90
backend/electron/chrome-extensions.ts
··· 18 18 import { getDb } from './datastore.js'; 19 19 import { getProfileSession } from './session-partition.js'; 20 20 import { registerAll } from './chrome-api-polyfills/index.js'; 21 - import { buildOptionsCommandName } from './chrome-extensions-helpers.js'; 21 + import { buildOptionsCommandName, computeUiEntries } from './chrome-extensions-helpers.js'; 22 22 23 23 const DEBUG = !!process.env.DEBUG; 24 24 ··· 489 489 * Returns entries only for extensions that are currently loaded. 490 490 */ 491 491 export function getChromeExtensionUiEntries(): ChromeExtensionUiEntry[] { 492 - const entries: ChromeExtensionUiEntry[] = []; 493 - 494 - for (const [extId, loaded] of loadedExtensions) { 495 - const discovered = discoveredExtensions.get(extId); 496 - if (!discovered) continue; 497 - 498 - const manifest = discovered.manifest; 499 - const baseUrl = loaded.electronExtension.url; // chrome-extension://[id]/ 500 - 501 - // Action popup (action takes priority over browser_action for MV3) 502 - const popupPage = manifest.action?.default_popup || manifest.browser_action?.default_popup; 503 - if (popupPage) { 504 - entries.push({ 505 - extensionId: extId, extensionName: discovered.name, type: 'popup', 506 - title: manifest.action?.default_title || manifest.browser_action?.default_title || discovered.name, 507 - url: `${baseUrl}${popupPage}`, width: 400, height: 500, openable: true, 508 - }); 509 - } 510 - 511 - // Options page (options_ui takes priority over options_page) 512 - const optionsPage = manifest.options_ui?.page || manifest.options_page; 513 - if (optionsPage) { 514 - entries.push({ 515 - extensionId: extId, extensionName: discovered.name, type: 'options', 516 - title: `${discovered.name} Settings`, 517 - url: `${baseUrl}${optionsPage}`, width: 1024, height: 768, openable: true, 518 - }); 519 - } 520 - 521 - // Background page (informational - service workers are not directly openable) 522 - const bgPage = manifest.background?.page; 523 - const bgWorker = manifest.background?.service_worker; 524 - const bgScripts = manifest.background?.scripts; 525 - if (bgPage || bgWorker || bgScripts) { 526 - const bgPath = bgPage || bgWorker || (bgScripts && bgScripts[0]) || ''; 527 - entries.push({ 528 - extensionId: extId, extensionName: discovered.name, type: 'background', 529 - title: `${discovered.name} Background`, 530 - url: bgPath ? `${baseUrl}${bgPath}` : '', 531 - width: 0, height: 0, openable: !!bgPage, 532 - }); 533 - } 534 - 535 - // Chrome URL overrides 536 - if (manifest.chrome_url_overrides) { 537 - const overrides = manifest.chrome_url_overrides; 538 - if (overrides.newtab) { 539 - entries.push({ 540 - extensionId: extId, extensionName: discovered.name, type: 'newtab', 541 - title: `${discovered.name} New Tab`, 542 - url: `${baseUrl}${overrides.newtab}`, width: 1024, height: 768, openable: true, 543 - }); 544 - } 545 - if (overrides.history) { 546 - entries.push({ 547 - extensionId: extId, extensionName: discovered.name, type: 'history', 548 - title: `${discovered.name} History`, 549 - url: `${baseUrl}${overrides.history}`, width: 1024, height: 768, openable: true, 550 - }); 551 - } 552 - if (overrides.bookmarks) { 553 - entries.push({ 554 - extensionId: extId, extensionName: discovered.name, type: 'bookmarks', 555 - title: `${discovered.name} Bookmarks`, 556 - url: `${baseUrl}${overrides.bookmarks}`, width: 1024, height: 768, openable: true, 557 - }); 558 - } 559 - } 560 - 561 - // DevTools page 562 - if (manifest.devtools_page) { 563 - entries.push({ 564 - extensionId: extId, extensionName: discovered.name, type: 'devtools', 565 - title: `${discovered.name} DevTools`, 566 - url: `${baseUrl}${manifest.devtools_page}`, width: 1024, height: 768, openable: true, 567 - }); 568 - } 569 - 570 - // Side panel 571 - const sidePanelPath = manifest.side_panel?.default_path || manifest.sidebar_action?.default_panel; 572 - if (sidePanelPath) { 573 - entries.push({ 574 - extensionId: extId, extensionName: discovered.name, type: 'sidepanel', 575 - title: manifest.sidebar_action?.default_title || `${discovered.name} Side Panel`, 576 - url: `${baseUrl}${sidePanelPath}`, width: 400, height: 768, openable: true, 577 - }); 578 - } 492 + // Adapt module-level Maps to the shape expected by the pure helper so the 493 + // computation can be tested without an Electron session. The pure helper 494 + // (computeUiEntries in chrome-extensions-helpers.ts) is the standing 495 + // regression test surface for this logic. 496 + const loadedAdapted = new Map<string, { extensionUrl: string }>(); 497 + for (const [id, loaded] of loadedExtensions) { 498 + loadedAdapted.set(id, { extensionUrl: loaded.electronExtension.url }); 579 499 } 580 - 581 - return entries; 500 + return computeUiEntries(loadedAdapted, discoveredExtensions) as ChromeExtensionUiEntry[]; 582 501 } 583 502 /** 584 503 * Open a chrome extension's UI page in a BrowserWindow.
+8 -1
backend/electron/entry.ts
··· 844 844 }); 845 845 } 846 846 847 - // Load enabled chrome extensions, then register their UI pages as commands 847 + // Load enabled chrome extensions, register their UI pages as commands, 848 + // then broadcast chrome-extensions:loaded so the Settings → Extensions 849 + // pane can re-render its per-extension buttons. The broadcast is needed 850 + // because renderPrivacySettings() runs once at page-open and 851 + // loadEnabledChromeExtensions() is async — without the signal, a 852 + // settings window opened before loading completes shows no buttons 853 + // (this was the root cause of the 2026-04-29 regression). 848 854 loadEnabledChromeExtensions().then(() => { 849 855 registerChromeExtensionCommands(); 856 + publish(systemAddress, 'chrome-extensions:loaded', {}); 850 857 }).catch(err => { 851 858 console.error('[startup] Chrome extensions init failed:', err); 852 859 });