Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions apps/desktop/extensions/ai-sidebar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
44 changes: 33 additions & 11 deletions apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down Expand Up @@ -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
Expand Down
118 changes: 89 additions & 29 deletions apps/desktop/extensions/ai-sidebar/install-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
100 changes: 100 additions & 0 deletions apps/desktop/extensions/ai-sidebar/install-state.js
Original file line number Diff line number Diff line change
@@ -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)',
};
}
Loading
Loading