From aaac7c2a9699b92a81deb7ff9a1fd199ed610d49 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 06:14:59 +0000 Subject: [PATCH] fix(extension): don't offer a Web Store install for an extension that's already here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the MarkSyncr listing on chromewebstore.google.com and clicking "Add to TronBrowser" hung: the install dialog sat there spinning, never installed, and would not dismiss. MarkSyncr is bundled. build-release.sh fetches its CRX and stages it into extensions/marksyncr, and the launcher loads every extension subdir with --load-extension. The CRX's manifest carries a `key`, so the bundled copy claims the same extension id as the store listing (hjcjjcpialiakkalcgadnfnoomdaegjg). Clicking Add therefore asked Chromium to install a downloaded CRX over an extension it already had from a command-line (unpacked) location, which a CRX cannot replace. The prompt had nothing to complete and no failure to report, so it never resolved. install-helper.js offered that button unconditionally — it never asked whether the extension was already installed. It does now, and the rule is general rather than a special case for the one listing that exposed it: any already-installed extension has the same problem. The service worker looks the id up with chrome.management (content scripts don't get the API) and the button shows what can actually succeed — nothing when it is installed and enabled, Enable when it is installed and switched off, and an install only when it is genuinely absent. The lookup is bounded at 3s and every failure path resolves null, so an unknown answer falls back to the plain CRX install this button already did. A stalled registry must not be what decides whether the button works. Adds the `management` permission, which is what the lookup and the enable both need. The decision is pure and lives in install-state.js with 15 tests, including the MarkSyncr case that started this. Full desktop suite: 89 passing, eslint clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XbNfTZM5a6sH1A54dsf7ns --- apps/desktop/extensions/ai-sidebar/README.md | 32 ++++- .../extensions/ai-sidebar/background.js | 44 +++++-- .../extensions/ai-sidebar/install-helper.js | 118 ++++++++++++----- .../extensions/ai-sidebar/install-state.js | 100 +++++++++++++++ .../ai-sidebar/install-state.test.js | 119 ++++++++++++++++++ .../extensions/ai-sidebar/manifest.json | 1 + 6 files changed, 372 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/extensions/ai-sidebar/install-state.js create mode 100644 apps/desktop/extensions/ai-sidebar/install-state.test.js diff --git a/apps/desktop/extensions/ai-sidebar/README.md b/apps/desktop/extensions/ai-sidebar/README.md index 49289d6..dd03260 100644 --- a/apps/desktop/extensions/ai-sidebar/README.md +++ b/apps/desktop/extensions/ai-sidebar/README.md @@ -22,12 +22,40 @@ keeps Chrome-extension compatibility (PRD §Desktop). 3. Click the toolbar action to open the side panel. 4. Open **Settings** (⚙), pick a provider, paste your API key + model, Save. +## The Chrome Web Store button + +Ungoogled Chromium greys out Google's native "Add to Chrome", so `install-helper.js` +injects a working **Add to TronBrowser** button on Web Store detail pages. It resolves +the target through the service worker, which checks in this order: + +1. **Is the extension already installed?** If so the button does not offer an install. + Installing over an extension the browser already has leaves Chromium's prompt + spinning with nothing to complete and no way to dismiss it. This is not + hypothetical: TronBrowser bundles MarkSyncr and loads it with `--load-extension`, + and MarkSyncr's Web Store manifest carries a `key`, so the bundled copy claims the + same id as its store listing (`hjcjjcpialiakkalcgadnfnoomdaegjg`). Opening that + listing and clicking Add used to hang the browser's install dialog. An + installed-but-disabled extension gets an **Enable** button instead — installing + again could not have fixed that either. +2. **The TronBrowser store**, since we do not publish on the Chrome Web Store. +3. **The Chrome Web Store CRX**, which installs thanks to the launcher pre-seeding + `extension-mime-request-handling = Always prompt for install`. + +If the worker can't answer within 5s the button falls back to a plain CRX install, so +an unknown answer never makes it less capable than it was. + +Anything a bundled extension needs from the store — an update, a reinstall — has to go +through the bundle, not this button. A `--load-extension` copy outranks a downloaded +CRX and cannot be replaced by one. + ## Files | File | Role | | --- | --- | -| `manifest.json` | MV3 manifest (side_panel, storage, tabs, host permissions) | -| `background.js` | Opens the panel on action click | +| `manifest.json` | MV3 manifest (side_panel, storage, tabs, management, host permissions) | +| `background.js` | Opens the panel on action click; resolves store-install targets | +| `install-helper.js` | The "Add to TronBrowser" button on Web Store detail pages | +| `install-state.js` | Pure decision + `chrome.management` lookup behind that button | | `sidepanel.html/.css/.js` | The chat UI | | `options.html/.js` | Provider + key configuration | | `providers.js` | Provider endpoints + streaming chat (mirrors `@tronbrowser/model-providers`) | diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index 5d53b58..de08e77 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,3 +1,5 @@ +import { decideInstallTarget, lookupInstalled } from './install-state.js'; + // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel .setPanelBehavior({ openPanelOnActionClick: true }) @@ -71,18 +73,38 @@ async function resolveTronStore(slug, name) { // Let pages (e.g. the new tab) ask to open the side panel. chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg?.type === 'resolve-tron-store') { + if (msg?.type === 'resolve-install-target') { (async () => { - const ext = await resolveTronStore(msg.slug, msg.name).catch(() => null); - if (ext) { - sendResponse({ - found: true, - slug: ext.slug, - name: ext.name, - downloadUrl: `${TRON_STORE_API}/extensions/${encodeURIComponent(ext.slug)}/download`, - }); - } else { - sendResponse({ found: false }); + // The installed check comes first and decides the answer on its own: an + // extension already in this browser cannot be installed over, from either + // store, and offering it is what leaves the prompt spinning. chrome.management + // is only reachable here — content scripts don't get the API. + const [installed, ext] = await Promise.all([ + lookupInstalled(msg.id), + resolveTronStore(msg.slug, msg.name).catch(() => null), + ]); + sendResponse( + decideInstallTarget({ + installed, + tronDownloadUrl: ext + ? `${TRON_STORE_API}/extensions/${encodeURIComponent(ext.slug)}/download` + : null, + crxUrl: msg.crxUrl, + }), + ); + })(); + return true; // async sendResponse + } + + // Turn an installed-but-disabled extension back on, for the store page's button. + // Only the service worker holds the management permission. + if (msg?.type === 'enable-extension' && msg.id) { + (async () => { + try { + await chrome.management.setEnabled(msg.id, true); + sendResponse({ ok: true }); + } catch (err) { + sendResponse({ ok: false, error: String(err?.message || err) }); } })(); return true; // async sendResponse diff --git a/apps/desktop/extensions/ai-sidebar/install-helper.js b/apps/desktop/extensions/ai-sidebar/install-helper.js index cca1f5b..387dacd 100644 --- a/apps/desktop/extensions/ai-sidebar/install-helper.js +++ b/apps/desktop/extensions/ai-sidebar/install-helper.js @@ -13,6 +13,13 @@ // service worker which has host permissions). When a live TronBrowser-store // listing exists we install from there; only when it doesn't do we fall back to // the Chrome Web Store CRX. +// +// Before any of that, the worker checks whether the extension is ALREADY +// INSTALLED. Installing over an extension the browser already has leaves +// Chromium's install prompt spinning with nothing to complete and no way to +// dismiss it — which is exactly what the bundled MarkSyncr listing did, since the +// copy we ship unpacked claims the same id as its Web Store listing. See +// install-state.js. The button reflects what can actually succeed. (function () { // Chrome extension IDs are 32 chars in a-p. New store URL: @@ -42,19 +49,36 @@ '&x=' + encodeURIComponent('id=' + id + '&installsource=ondemand&uc'); } - // Ask the background worker whether the TronBrowser store has this extension. - // Resolves to a Tron-store download URL, or null to use the Chrome CRX. Never - // rejects — any failure (SW asleep, offline, not listed) falls back to Chrome. - function resolveTronDownload(slug, name) { + // Ask the background worker what this button should do: install from the + // TronBrowser store, install the Chrome CRX, enable an extension that's already + // here but off, or nothing at all because it's already running. + // + // Never rejects — any failure (SW asleep, offline) falls back to the plain + // Chrome CRX install, which is exactly what this button did before the check + // existed. An unknown answer must not make the button less capable. + function resolveTarget(detail) { + const fallback = { + action: 'navigate', + url: crxUrl(detail.id), + label: '⬇ Add to TronBrowser', + title: 'Install this extension (Ungoogled Chromium disables the native button)', + }; return new Promise((resolve) => { let settled = false; - const done = (v) => { if (!settled) { settled = true; resolve(v); } }; + const done = (v) => { if (!settled) { settled = true; resolve(v || fallback); } }; const timer = setTimeout(() => done(null), 5000); // never hang the click try { - chrome.runtime.sendMessage({ type: 'resolve-tron-store', slug, name }, (resp) => { + const msg = { + type: 'resolve-install-target', + id: detail.id, + slug: detail.slug, + name: extName(), + crxUrl: fallback.url, + }; + chrome.runtime.sendMessage(msg, (resp) => { clearTimeout(timer); - if (chrome.runtime.lastError || !resp || !resp.found || !resp.downloadUrl) { done(null); return; } - done(resp.downloadUrl); + if (chrome.runtime.lastError || !resp || !resp.action) { done(null); return; } + done(resp); }); } catch (_) { clearTimeout(timer); @@ -63,37 +87,73 @@ }); } + const BASE_STYLE = [ + 'position:fixed', 'right:18px', 'bottom:18px', 'z-index:2147483647', + 'border:0', 'border-radius:10px', 'padding:12px 18px', + 'font:700 14px ui-monospace,Menlo,monospace', + 'box-shadow:0 6px 24px rgba(0,0,0,.5)', + ]; + + // 'none' is a statement of fact, not a control: it must not look clickable, and + // clicking it must not start an install that cannot finish. + function paint(btn, target) { + btn.textContent = target.label; + btn.title = target.title; + const inert = target.action === 'none'; + btn.disabled = inert; + btn.style.cssText = BASE_STYLE.concat([ + inert ? 'background:#1b2431' : 'background:#34e7ff', + inert ? 'color:#7fe9a0' : 'color:#04060c', + inert ? 'cursor:default' : 'cursor:pointer', + inert ? 'opacity:.95' : 'opacity:1', + ]).join(';'); + } + + function enable(id) { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 5000); + try { + chrome.runtime.sendMessage({ type: 'enable-extension', id }, (resp) => { + clearTimeout(timer); + resolve(!chrome.runtime.lastError && !!resp?.ok); + }); + } catch (_) { + clearTimeout(timer); + resolve(false); + } + }); + } + function addButton() { const detail = parseDetail(); if (!detail || document.getElementById('tron-install-btn')) return; - const { slug, id } = detail; const btn = document.createElement('button'); btn.id = 'tron-install-btn'; btn.type = 'button'; - btn.textContent = '⬇ Add to TronBrowser'; - btn.title = 'Install this extension (Ungoogled Chromium disables the native button)'; - // Programmatic CSSOM styling — not subject to the page's CSP. - btn.style.cssText = [ - 'position:fixed', 'right:18px', 'bottom:18px', 'z-index:2147483647', - 'background:#34e7ff', 'color:#04060c', 'border:0', 'border-radius:10px', - 'padding:12px 18px', 'font:700 14px ui-monospace,Menlo,monospace', - 'cursor:pointer', 'box-shadow:0 6px 24px rgba(0,0,0,.5)', - ].join(';'); - // Resolve the TronBrowser store up front so the button reflects where the - // install will come from; cache the promise so a click never re-resolves. - const tronTarget = resolveTronDownload(slug, extName()); - tronTarget.then((url) => { - if (url) { - btn.textContent = '⬇ Add from TronBrowser Store'; - btn.title = 'Install from the TronBrowser store (not published on the Chrome Web Store)'; - } + // Start on the plain-install look so the button is usable before the worker + // answers, then repaint with whatever it says. Cache the promise so a click + // never re-resolves. + const resolved = resolveTarget(detail); + paint(btn, { + action: 'navigate', + label: '⬇ Add to TronBrowser', + title: 'Checking whether this extension is already installed…', }); + resolved.then((target) => paint(btn, target)); btn.addEventListener('click', async () => { - // Tron store FIRST, Chrome Web Store CRX as the fallback. - const url = (await tronTarget) || crxUrl(id); - window.location.href = url; + const target = await resolved; + // Already installed and enabled: there is nothing an install could do. + if (target.action === 'none') return; + if (target.action === 'enable') { + const ok = await enable(target.id); + paint(btn, ok + ? { action: 'none', label: '✓ Enabled in TronBrowser', title: 'Turned back on. Open chrome://extensions to review it.' } + : { action: 'none', label: '✗ Could not enable', title: 'Enabling failed. Turn it on from chrome://extensions.' }); + return; + } + window.location.href = target.url; }); document.body.appendChild(btn); } diff --git a/apps/desktop/extensions/ai-sidebar/install-state.js b/apps/desktop/extensions/ai-sidebar/install-state.js new file mode 100644 index 0000000..9008259 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/install-state.js @@ -0,0 +1,100 @@ +// What the Chrome Web Store page's "Add to TronBrowser" button should actually do. +// +// The button used to install unconditionally, and that is how it hangs. TronBrowser +// bundles MarkSyncr and loads it with --load-extension, and MarkSyncr's Web Store +// manifest carries a `key`, so the bundled copy claims the SAME extension id as the +// store listing (hjcjjcpialiakkalcgadnfnoomdaegjg). Clicking Add on that listing asks +// Chromium to install a downloaded CRX over an extension it already has from a +// command-line (unpacked) location — which a CRX can never replace. The install prompt +// has nothing to complete, so it sits there spinning and will not dismiss. +// +// Any already-installed extension has the same problem, not just the bundled one, so +// the rule is general: look the id up before offering to install it, and offer the +// action that can actually succeed — nothing when it is already there and enabled, +// enabling it when it is there and switched off, installing only when it is absent. +// +// Deciding this is pure and lives here so it can be tested without a browser. The +// lookup itself needs chrome.management, which only the service worker has. + +/** + * Budget for the chrome.management lookup. It reads the local extension registry and + * should be instant; anything near this is already broken, and a lookup that never + * settles must not be what decides whether the button appears. + */ +export const MANAGEMENT_TIMEOUT_MS = 3000; + +/** + * Look an extension id up in this browser. Resolves to a small record when it is + * installed, or null when it isn't — and null, too, on any failure (no management + * permission, a stalled registry). Never rejects: an unknown answer must leave the + * button exactly as capable as it was before this check existed. + */ +export async function lookupInstalled(id, management = globalThis.chrome?.management) { + if (!id || !management?.get) return null; + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), MANAGEMENT_TIMEOUT_MS)); + const lookup = (async () => { + try { + const info = await management.get(id); + if (!info) return null; + return { + id: info.id, + name: info.name, + version: info.version, + enabled: info.enabled !== false, + // 'development' is what --load-extension produces: the copy we bundle. + bundled: info.installType === 'development', + }; + } catch (_) { + // management.get rejects for an id that isn't installed. That is the common + // case and the answer we want, not an error. + return null; + } + })(); + return Promise.race([lookup, timeout]); +} + +/** + * Decide the button. Pure. + * + * @param {object} state + * @param {?object} state.installed Result of `lookupInstalled`, or null. + * @param {?string} state.tronDownloadUrl TronBrowser-store download, when listed there. + * @param {?string} state.crxUrl Chrome Web Store CRX, the fallback. + * @returns {{action: 'none'|'enable'|'navigate', url?: string, id?: string, label: string, title: string}} + */ +export function decideInstallTarget({ installed, tronDownloadUrl, crxUrl } = {}) { + if (installed) { + if (!installed.enabled) { + return { + action: 'enable', + id: installed.id, + label: '⏻ Enable in TronBrowser', + title: `${installed.name || 'This extension'} is already installed but switched off. Installing again cannot fix that — this turns it back on.`, + }; + } + return { + action: 'none', + id: installed.id, + label: installed.bundled ? '✓ Bundled with TronBrowser' : '✓ Already in TronBrowser', + title: installed.bundled + ? `${installed.name || 'This extension'} ${installed.version ? `v${installed.version} ` : ''}ships with TronBrowser and is already running. Installing it from the Web Store cannot replace the bundled copy — the prompt would never finish.` + : `${installed.name || 'This extension'} ${installed.version ? `v${installed.version} ` : ''}is already installed and enabled.`, + }; + } + + if (tronDownloadUrl) { + return { + action: 'navigate', + url: tronDownloadUrl, + label: '⬇ Add from TronBrowser Store', + title: 'Install from the TronBrowser store (not published on the Chrome Web Store)', + }; + } + + return { + action: 'navigate', + url: crxUrl, + label: '⬇ Add to TronBrowser', + title: 'Install this extension (Ungoogled Chromium disables the native button)', + }; +} diff --git a/apps/desktop/extensions/ai-sidebar/install-state.test.js b/apps/desktop/extensions/ai-sidebar/install-state.test.js new file mode 100644 index 0000000..d70aad0 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/install-state.test.js @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest'; +import { decideInstallTarget, lookupInstalled } from './install-state.js'; + +const CRX = 'https://clients2.google.com/service/update2/crx?x=id%3Dabc'; +const TRON = 'https://tronbrowser.dev/api/store/extensions/thing/download'; + +// The listing that started this: MarkSyncr ships bundled and loaded unpacked, and +// its Web Store manifest carries a `key`, so the bundled copy holds the same id as +// the listing. A CRX cannot install over it, and the prompt never dismisses. +const MARKSYNCR = { + id: 'hjcjjcpialiakkalcgadnfnoomdaegjg', + name: 'MarkSyncr', + version: '0.8.40', + enabled: true, + bundled: true, +}; + +describe('decideInstallTarget', () => { + it('offers nothing for an extension that is already installed and enabled', () => { + const t = decideInstallTarget({ installed: MARKSYNCR, tronDownloadUrl: null, crxUrl: CRX }); + expect(t.action).toBe('none'); + expect(t.url).toBeUndefined(); + }); + + it('says the copy is bundled when it came from --load-extension', () => { + const t = decideInstallTarget({ installed: MARKSYNCR, crxUrl: CRX }); + expect(t.label).toContain('Bundled'); + expect(t.title).toContain('MarkSyncr'); + }); + + it('distinguishes an extension the user installed themselves', () => { + const t = decideInstallTarget({ installed: { ...MARKSYNCR, bundled: false }, crxUrl: CRX }); + expect(t.action).toBe('none'); + expect(t.label).toContain('Already in TronBrowser'); + }); + + it('refuses to install even when the TronBrowser store also lists it', () => { + // Being listed in our own store does not make a second install possible: the id + // is taken either way. + const t = decideInstallTarget({ installed: MARKSYNCR, tronDownloadUrl: TRON, crxUrl: CRX }); + expect(t.action).toBe('none'); + }); + + it('offers to enable, not install, when it is installed but switched off', () => { + const t = decideInstallTarget({ installed: { ...MARKSYNCR, enabled: false }, crxUrl: CRX }); + expect(t.action).toBe('enable'); + expect(t.id).toBe(MARKSYNCR.id); + expect(t.label).toContain('Enable'); + }); + + it('prefers the TronBrowser store when the extension is absent', () => { + const t = decideInstallTarget({ installed: null, tronDownloadUrl: TRON, crxUrl: CRX }); + expect(t).toMatchObject({ action: 'navigate', url: TRON }); + }); + + it('falls back to the Chrome Web Store CRX when nothing else applies', () => { + const t = decideInstallTarget({ installed: null, tronDownloadUrl: null, crxUrl: CRX }); + expect(t).toMatchObject({ action: 'navigate', url: CRX }); + }); + + it('still offers the CRX install when called with nothing', () => { + expect(decideInstallTarget().action).toBe('navigate'); + }); +}); + +describe('lookupInstalled', () => { + it('reports an installed extension', async () => { + const management = { + get: vi.fn().mockResolvedValue({ + id: 'abc', name: 'Thing', version: '1.2.3', enabled: true, installType: 'normal', + }), + }; + await expect(lookupInstalled('abc', management)).resolves.toEqual({ + id: 'abc', name: 'Thing', version: '1.2.3', enabled: true, bundled: false, + }); + }); + + it('marks a --load-extension copy as bundled', async () => { + const management = { + get: vi.fn().mockResolvedValue({ id: 'abc', name: 'T', version: '1', enabled: true, installType: 'development' }), + }; + await expect(lookupInstalled('abc', management)).resolves.toMatchObject({ bundled: true }); + }); + + it('treats a disabled extension as installed', async () => { + const management = { + get: vi.fn().mockResolvedValue({ id: 'abc', name: 'T', version: '1', enabled: false, installType: 'normal' }), + }; + await expect(lookupInstalled('abc', management)).resolves.toMatchObject({ enabled: false }); + }); + + it('resolves null when the id is not installed', async () => { + // management.get rejects for an unknown id; that is the answer, not an error. + const management = { get: vi.fn().mockRejectedValue(new Error('No extension with id')) }; + await expect(lookupInstalled('abc', management)).resolves.toBeNull(); + }); + + it('resolves null rather than hanging when the registry never answers', async () => { + vi.useFakeTimers(); + try { + const management = { get: vi.fn().mockImplementation(() => new Promise(() => {})) }; + const p = lookupInstalled('abc', management); + await vi.advanceTimersByTimeAsync(3000); + await expect(p).resolves.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('resolves null when the management API is missing entirely', async () => { + await expect(lookupInstalled('abc', undefined)).resolves.toBeNull(); + }); + + it('resolves null for a page with no extension id', async () => { + const management = { get: vi.fn() }; + await expect(lookupInstalled('', management)).resolves.toBeNull(); + expect(management.get).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json index 47dbd63..1fc5767 100644 --- a/apps/desktop/extensions/ai-sidebar/manifest.json +++ b/apps/desktop/extensions/ai-sidebar/manifest.json @@ -12,6 +12,7 @@ "permissions": [ "storage", "sidePanel", + "management", "tabs", "activeTab", "scripting",