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",