diff --git a/.gitignore b/.gitignore index 704f13f..9fbac08 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage/ /popup.js /popup.css /popup.html +/firefox/ .worktree-backup/ .env .env.* diff --git a/.prettierignore b/.prettierignore index b5104c3..691721b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,4 @@ bun.lock /popup.css /popup.html .worktree-backup +/firefox diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ab8eff..a830d9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ Technical notes for working on Sharp. For what it does and how to install it, see the [README](README.md). -**TypeScript · Effect · Preact · esbuild · Chrome Manifest V3** +**TypeScript · Effect · Preact · esbuild · Manifest V3 (Chrome and Firefox)** ## Toolchain @@ -12,12 +12,14 @@ Use [Bun](https://bun.sh/) 1.3.10 or newer (CI uses 1.3.10). ```sh bun install --frozen-lockfile bun run dev # rebuild scripts and styles as you edit +bun run dev:firefox # the same, into firefox/ bun run build:debug # one unminified build with inline source maps +bun run build:firefox # one production build into firefox/ bun run typecheck # strict TypeScript, without emitting bun run test # focused provider, storage, cache, RPC and menu checks bun run format # format source and docs -bun run check # types + tests + formatting + production build -bun run release # build, then zip it for a GitHub release +bun run check # types + tests + formatting + both production builds +bun run release # build both targets, then zip each for a release bun scripts/icon.mjs # regenerate icons/ from geometry ``` @@ -33,11 +35,17 @@ executing a malicious dependency later during builds or tests. ## Build output -The build writes the bundled scripts, styles, and popup HTML **beside +The Chrome build writes the bundled scripts, styles, and popup HTML **beside `manifest.json` in the repository root**, not into a subdirectory. This keeps the original unpacked-extension path and ID. No runtime code is loaded from a CDN. +`--target=firefox` writes a complete second build into `firefox/`, generated +manifest included. It is compiled, not copied: the target is a build-time +constant, so each bundle carries only its own browser's branch. Load +`firefox/manifest.json` from **about:debugging → This Firefox → Load Temporary +Add-on**. `firefox/` is generated and ignored by Git. + `bun run build` minifies. When a stack trace points at `content.js:45`, use `bun run build:debug` (or `bun run dev`): readable names, inline source maps, so Chrome shows the TypeScript line. Do not ship a debug build; `release` rebuilds. @@ -59,6 +67,75 @@ The popup's **Build** timestamp identifies its compiled code; on X, `document.documentElement.dataset.aitfBuild` should return the same timestamp. Missing or different values identify a missing or stale content script. +## Firefox + +One source tree, two Manifest V3 browsers. Everything that differs is listed +here; there is no polyfill, because the extension only ever calls promise-based +`chrome.*` APIs and Firefox provides those under the same name. + +`scripts/manifest.mjs` derives Firefox's manifest from Chrome's: + +- **Background.** Firefox has no extension service worker, so + `background.service_worker` becomes `background.scripts` on an event page. The + bundle is unchanged: nothing in `src/background/` touches a worker-only + global, and an event page is suspended and revived the same way, which the + code already assumes. +- **`browser_specific_settings.gecko.id`.** AMO keys the add-on on it, and + `storage.sync` — read once, to migrate API keys off it — has nowhere to write + without it. It must never change between releases. +- **`strict_min_version`.** What the code needs is 133: 128 brought MAIN-world + content scripts, module event pages and `optional_host_permissions`, and 133 + brought `storage.local.getBytesInUse`, which the popup uses to size the + verdict cache. The floor is 140 anyway, because that is where + `data_collection_permissions` starts being honoured, and 140 is the current + ESR, so nothing still supported is excluded. +- **`data_collection_permissions`.** AMO refuses a new add-on without it. Sharp + declares `websiteContent` as required: nothing reaches the developer, there + being no server, but post text, handles and reply context do go to the AI + provider the reader configured, and that is the extension's whole function + rather than something optional. Keep it consistent with + [privacy-policy.md](privacy-policy.md). + +Two things differ at runtime, both behind `isFirefox` in `src/common/build.ts`, +which is a build-time constant so the other browser's branch is dropped from +the bundle: + +- **Answering a message.** Chrome answers only through `sendResponse`, and only + if the listener returns `true` synchronously to claim the channel. Firefox + answers with the promise the listener returns and ignores a claimed channel. + `src/background/index.ts` settles one promise and hands it over whichever way + the browser expects. +- **Naming the extensions page** in the "Sharp was updated, reload it" error. + +Host permissions are the one real behavioural difference, and it is not +papered over. Chrome grants declared `host_permissions` at install. Firefox +treats them as optional even when declared, so until the reader grants them the +content scripts never inject and the provider is unreachable — indistinguishable +from a broken extension. The popup checks `permissions.contains` for the +filtered sites and the configured provider's origin and, if any are missing, +says so and offers a button that asks for exactly those. The request has to be +the first thing the click does: Firefox rejects one made after an `await`, +outside the user gesture. Granting does not inject into tabs that are already +open, so the banner says to reload them. + +`bun run release` produces `sharp--chrome.zip` and +`sharp--firefox.zip`. 0.3.0 is the exception: its Chrome archive was +published as `sharp-0.3.0.zip`, before there was a second target, and keeps that +name and URL — link it as-is rather than as `sharp-0.3.0-chrome.zip`. Every +release from 0.4.0 names both by target. + +The Firefox archive goes to AMO, which signs it. Check it first with Mozilla's +own validator: + +```sh +bun run build:firefox && bunx web-ext lint --source-dir=firefox --self-hosted +``` + +It should report no errors. Three warnings are expected and not worth chasing: +`data_collection_permissions` postdates the Android floor, and Preact's +`dangerouslySetInnerHTML` path assigns to `innerHTML` inside its own bundle — +no source file in `src/` touches `innerHTML`. + ## Code map ```text @@ -72,7 +149,7 @@ src/ youtube/ Toggle-driven page rules: three attributes on , one stylesheet popup/ Rail, per-site and general sections, model browser, list sheets tests/ Focused provider/storage, cache, RPC and menu checks -scripts/ Three-entry extension build, and the icon generator +scripts/ Per-target extension build, manifest derivation, release, icons ``` **Site entry points are inert until called.** `src/index.ts` dispatches HTTPS diff --git a/README.md b/README.md index 8f9a49d..1b1792a 100644 --- a/README.md +++ b/README.md @@ -58,19 +58,32 @@ else. ## Install -From the [Chrome Web Store](https://chromewebstore.google.com/detail/sharp/colokgnfkjacfaahjbionncilmjioedo), or from source: +**Chrome and other Chromium browsers:** from the +[Chrome Web Store](https://chromewebstore.google.com/detail/sharp/colokgnfkjacfaahjbionncilmjioedo). -Sharp isn't in the Chrome Web Store yet. Grab the zip from -[Releases](https://github.com/tshmieldev/sharp/releases) and unzip it, or build -it yourself with [Bun](https://bun.sh): +**Firefox:** submitted to [addons.mozilla.org](https://addons.mozilla.org/) and +awaiting review. Until it is signed, grab `sharp--firefox.zip` from +[Releases](https://github.com/tshmieldev/sharp/releases) or build it from source +below, and load it as a temporary add-on. Firefox 140 or newer. + +From source, with [Bun](https://bun.sh): ```sh git clone https://github.com/tshmieldev/sharp -cd sharp && bun install && bun run build +cd sharp && bun install +bun run build # Chrome, into the repository root +bun run build:firefox # Firefox, into firefox/ ``` -Then open `chrome://extensions`, turn on **Developer mode**, click **Load -unpacked**, and pick the folder. +In Chrome, open `chrome://extensions`, turn on **Developer mode**, click **Load +unpacked**, and pick the repository folder. + +In Firefox, open `about:debugging` → **This Firefox** → **Load Temporary +Add-on**, and pick `firefox/manifest.json`. A temporary add-on is gone after a +restart, which is why the AMO listing matters. Firefox hands out site access one +origin at a time, so open Sharp and use **Grant access** if it says it has none +for x.com, youtube.com or your provider; reload any tabs that were already +open. ## Set it up diff --git a/package.json b/package.json index b1c94f9..5f9769a 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,16 @@ "trustedDependencies": [], "scripts": { "build": "bun scripts/build.mjs", + "build:firefox": "bun scripts/build.mjs --target=firefox", "build:debug": "bun scripts/build.mjs --debug", "release": "bun scripts/release.mjs", "dev": "bun scripts/build.mjs --watch", + "dev:firefox": "bun scripts/build.mjs --watch --target=firefox", "typecheck": "tsc --noEmit", "test": "vitest run", "format": "prettier --write .", "format:check": "prettier --check .", - "check": "bun run typecheck && bun run test && bun run format:check && bun run build" + "check": "bun run typecheck && bun run test && bun run format:check && bun run build && bun run build:firefox" }, "dependencies": { "effect": "^3.19.0", diff --git a/privacy-policy.md b/privacy-policy.md index fb6a3cb..814e2af 100644 --- a/privacy-policy.md +++ b/privacy-policy.md @@ -51,6 +51,8 @@ or [Anthropic](https://www.anthropic.com/legal/privacy). - **Storage** — to save your settings, key, and decision cache locally. - **Access to x.com and twitter.com** — to read posts on the page and hide the ones that match your criteria. +- **Access to www.youtube.com** — to apply the YouTube page rules you turn on. + Nothing on YouTube is read or sent anywhere; the rules are stylesheet rules. - **Access to your provider's API** (`openrouter.ai`, `api.openai.com`, `api.anthropic.com`, or a custom endpoint you enter) — to send classification requests. A custom endpoint asks for its own permission when you save it. diff --git a/scripts/build.mjs b/scripts/build.mjs index 50022eb..95edc7d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,19 +1,41 @@ import { context } from 'esbuild'; -import { access, cp, readFile } from 'node:fs/promises'; +import { access, cp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { assetsOf, manifestFor } from './manifest.mjs'; -const watch = process.argv.includes('--watch'); -// Readable output with inline source maps, for reading stack traces in Chrome. -// Watching implies it; a one-off `--debug` build gets the same without watching. -const debug = watch || process.argv.includes('--debug'); -const manifest = JSON.parse(await readFile('manifest.json', 'utf8')); -// Keep the repository root loadable, preserving the unpacked extension's ID -// and local settings when replacing the original JavaScript implementation. -await cp('src/popup/index.html', 'popup.html'); +const args = process.argv.slice(2); +const watch = args.includes('--watch'); +// Readable output with inline source maps, for reading stack traces in the +// browser. Watching implies it; a one-off `--debug` build gets the same +// without watching. +const debug = watch || args.includes('--debug'); +const flag = args.find((arg) => arg.startsWith('--target=')); +const target = flag ? flag.slice('--target='.length) : 'chrome'; +if (target !== 'chrome' && target !== 'firefox') { + throw new Error(`Unknown ${flag}. Use --target=chrome or --target=firefox.`); +} +const base = JSON.parse(await readFile('manifest.json', 'utf8')); +const manifest = manifestFor(base, target); + +// Chrome's build stays at the repository root, preserving the unpacked +// extension's ID and local settings across rebuilds. Firefox needs a different +// manifest, so its build gets a directory of its own to point +// about:debugging at — the JavaScript and CSS in it are freshly compiled, not +// copies of Chrome's. +const out = target === 'firefox' ? 'firefox/' : ''; +if (out) { + await mkdir(out, { recursive: true }); + await cp('icons', `${out}icons`, { recursive: true }); + await writeFile(`${out}manifest.json`, `${JSON.stringify(manifest, null, 2)}\n`); +} +await cp('src/popup/index.html', `${out}popup.html`); const options = { bundle: true, - define: { __BUILD_ID__: JSON.stringify(new Date().toISOString()) }, - target: 'chrome120', + define: { + __BUILD_ID__: JSON.stringify(new Date().toISOString()), + __TARGET__: JSON.stringify(target), + }, + target: target === 'firefox' ? 'firefox133' : 'chrome120', sourcemap: debug ? 'inline' : false, minify: !debug, legalComments: 'none', @@ -23,14 +45,14 @@ const builds = await Promise.all([ context({ ...options, entryPoints: ['src/background/index.ts'], - outfile: 'background.js', + outfile: `${out}background.js`, format: 'esm', }), - // Chrome content scripts are classic scripts, not ES modules. + // Content scripts are classic scripts, not ES modules. context({ ...options, entryPoints: ['src/index.ts'], - outfile: 'content.js', + outfile: `${out}content.js`, format: 'iife', }), // Runs in the page's own world (manifest `world: MAIN`): no chrome.*, and @@ -38,33 +60,33 @@ const builds = await Promise.all([ context({ ...options, entryPoints: ['src/x/wire.ts'], - outfile: 'wire.js', + outfile: `${out}wire.js`, format: 'iife', }), context({ ...options, entryPoints: ['src/popup/index.tsx'], - outfile: 'popup.js', + outfile: `${out}popup.js`, format: 'esm', }), ]); if (watch) { await Promise.all(builds.map((build) => build.watch())); - console.log('Watching source files. Reload the extension and X tabs after changes.'); + console.log( + `Watching source files for ${target}. Reload the extension and X tabs after changes.`, + ); } else { try { await Promise.all(builds.map((build) => build.rebuild())); - const assets = [ - manifest.background.service_worker, - manifest.action.default_popup, - ...Object.values(manifest.icons), - ...manifest.content_scripts.flatMap((script) => [...script.js, ...(script.css ?? [])]), - 'popup.js', - 'popup.css', - ]; - await Promise.all(assets.map((asset) => access(asset))); + // `manifest.json` is written above for Firefox and committed for Chrome, + // so only Chrome's root needs it checked from the repository. + await Promise.all(assetsOf(manifest).map((asset) => access(`${out}${asset}`))); + const where = out ? `${process.cwd()}/${out.replace(/\/$/, '')}` : process.cwd(); console.log( - `Built ${debug ? 'a debug build' : 'the extension'} in ${process.cwd()}. Load this directory in Chrome.`, + `Built ${debug ? 'a debug build' : 'the extension'} for ${target} in ${where}.\n` + + (target === 'firefox' + ? 'Load firefox/manifest.json in about:debugging → This Firefox → Load Temporary Add-on.' + : 'Load this directory in Chrome.'), ); } finally { await Promise.all(builds.map((build) => build.dispose())); diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs new file mode 100644 index 0000000..ea3a921 --- /dev/null +++ b/scripts/manifest.mjs @@ -0,0 +1,55 @@ +// The manifest in the repository is Chrome's, so the checkout stays loadable +// unpacked. Everything Firefox needs differently lives here. + +/** AMO keys an add-on on this. It must not change between releases, and + * `storage.sync` needs it to have somewhere to write. */ +export const GECKO_ID = 'sharp@tshmieldev.github.io'; +/** What the extension needs to run at all: 128 for MAIN-world content scripts, + * module event pages and `optional_host_permissions`, 133 for + * `storage.local.getBytesInUse`. The floor is 140 because that is where + * `data_collection_permissions` below starts being honoured, and 140 is the + * current ESR, so nothing still supported is excluded. */ +export const GECKO_MIN_VERSION = '140.0'; + +/** The manifest a target actually loads, given the Chrome one in the + * repository. Chrome's is returned unchanged; Firefox's differs only in the + * keys below. */ +export function manifestFor(base, target) { + if (target !== 'firefox') return base; + const { background, ...rest } = base; + return { + ...rest, + // Firefox MV3 runs the background as an event page, not a service worker. + // Same bundle either way: the code touches no worker-only globals. + background: { scripts: [background.service_worker], type: background.type }, + browser_specific_settings: { + gecko: { + id: GECKO_ID, + strict_min_version: GECKO_MIN_VERSION, + // AMO requires this declaration. Nothing reaches the developer — there + // is no server — but post text, handles and reply context do go to the + // AI provider the reader configured, which is a third party, and that + // is the whole point of the extension rather than something optional. + // See privacy-policy.md. + data_collection_permissions: { required: ['websiteContent'] }, + }, + }, + }; +} + +/** Every file a packaged build contains, relative to its own root. */ +export function assetsOf(manifest) { + const background = manifest.background.service_worker ?? manifest.background.scripts[0]; + return [ + ...new Set([ + 'manifest.json', + background, + manifest.action.default_popup, + ...Object.values(manifest.icons), + ...Object.values(manifest.action.default_icon), + ...manifest.content_scripts.flatMap((script) => [...script.js, ...(script.css ?? [])]), + 'popup.js', + 'popup.css', + ]), + ]; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index cb652de..c6b95e5 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,53 +1,67 @@ import { execFileSync } from 'node:child_process'; -import { access, cp, mkdir, rm, stat } from 'node:fs/promises'; -import { readFile } from 'node:fs/promises'; +import { access, cp, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { assetsOf, manifestFor } from './manifest.mjs'; -// Builds, then stages exactly what Chrome loads into a zip with manifest.json at -// the root, which is what both "Load unpacked" and the Web Store expect. +// Builds, then stages exactly what a browser loads into a zip with +// manifest.json at the root, which is what "Load unpacked", the Chrome Web +// Store and AMO all expect. One zip per target. const STAGE = '.release'; -const manifest = JSON.parse(await readFile('manifest.json', 'utf8')); -const output = `sharp-${manifest.version}.zip`; - -console.log('Building…'); -execFileSync('bun', ['scripts/build.mjs'], { stdio: 'inherit' }); - -const files = [ - 'manifest.json', - manifest.background.service_worker, - manifest.action.default_popup, - ...new Set([...Object.values(manifest.icons), ...Object.values(manifest.action.default_icon)]), - ...manifest.content_scripts.flatMap((script) => [...script.js, ...(script.css ?? [])]), - 'popup.js', - 'popup.css', -]; -const unique = [...new Set(files)]; - -// A missing asset must fail here, not silently ship a broken zip. -await Promise.all( - unique.map((file) => - access(file).catch(() => { - throw new Error(`Missing ${file}. Run \`bun run build\` and try again.`); - }), - ), -); +const base = JSON.parse(await readFile('manifest.json', 'utf8')); +const targets = ['chrome', 'firefox']; +const built = []; await rm(STAGE, { recursive: true, force: true }); -await mkdir(STAGE, { recursive: true }); -for (const file of unique) { - await cp(file, `${STAGE}/${file}`, { recursive: true }); -} -await rm(output, { force: true }); -try { - execFileSync('zip', ['-r', '-q', '-X', `../${output}`, '.'], { cwd: STAGE, stdio: 'inherit' }); -} catch (error) { - throw new Error(`Could not run \`zip\`. Install it, or zip ${STAGE}/ by hand. ${error.message}`); +for (const target of targets) { + console.log(`Building ${target}…`); + execFileSync('bun', ['scripts/build.mjs', `--target=${target}`], { stdio: 'inherit' }); + + // Chrome builds into the repository root so the checkout stays loadable + // unpacked; Firefox builds into a directory of its own. + const root = target === 'firefox' ? 'firefox' : '.'; + const files = assetsOf(manifestFor(base, target)); + const stage = `${STAGE}/${target}`; + const output = `sharp-${base.version}-${target}.zip`; + + // A missing asset must fail here, not silently ship a broken zip. + await Promise.all( + files.map((file) => + access(`${root}/${file}`).catch(() => { + throw new Error(`Missing ${root}/${file}. Run \`bun run build\` and try again.`); + }), + ), + ); + + await mkdir(stage, { recursive: true }); + for (const file of files) { + await cp(`${root}/${file}`, `${stage}/${file}`, { recursive: true }); + } + + await rm(output, { force: true }); + try { + execFileSync('zip', ['-r', '-q', '-X', `../../${output}`, '.'], { + cwd: stage, + stdio: 'inherit', + }); + } catch (error) { + throw new Error( + `Could not run \`zip\`. Install it, or zip ${stage}/ by hand. ${error.message}`, + ); + } + built.push({ output, files: files.length }); } + await rm(STAGE, { recursive: true, force: true }); -const { size } = await stat(output); -console.log(`\n${output} ${Math.round(size / 1024)} KB ${unique.length} files`); +console.log(''); +for (const { output, files } of built) { + const { size } = await stat(output); + console.log(`${output} ${Math.round(size / 1024)} KB ${files} files`); +} console.log( - `\nPublish it with:\n gh release create v${manifest.version} ${output} --generate-notes`, + `\nPublish them with:\n gh release create v${base.version} ${built + .map((entry) => entry.output) + .join(' ')} --generate-notes`, ); +console.log(`\nThe Firefox zip is what AMO takes at https://addons.mozilla.org/developers/.`); diff --git a/src/background/index.ts b/src/background/index.ts index 96ac171..347af31 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from 'effect'; +import { isFirefox } from '../common/build'; import { errorMessage, OperationError } from '../common/errors'; import { Request } from '../common/messages'; import { threadId } from '../common/post'; @@ -120,16 +121,19 @@ chrome.runtime.onMessage.addListener((raw: unknown, sender, respond) => { } return yield* handle(message); }); - Effect.runPromise( + const settled = Effect.runPromise( program.pipe( Effect.match({ onSuccess: (result) => ({ ok: true, result }), onFailure: (error) => ({ ok: false, error: errorMessage(error) }), }), ), - ).then(respond, () => - respond({ ok: false, error: 'Unexpected extension error. Reload the extension.' }), - ); + ).catch(() => ({ ok: false, error: 'Unexpected extension error. Reload the extension.' })); + // Firefox answers with whatever promise the listener returns. Chrome ignores + // a returned promise and answers only through `respond`, and only if the + // listener claims the channel by returning `true` synchronously. + if (isFirefox) return settled; + void settled.then(respond); return true; }); diff --git a/src/common/build.ts b/src/common/build.ts index 86267f9..aed3257 100644 --- a/src/common/build.ts +++ b/src/common/build.ts @@ -1,2 +1,9 @@ declare const __BUILD_ID__: string; +declare const __TARGET__: string; + export const buildId = typeof __BUILD_ID__ === 'string' ? __BUILD_ID__ : 'development'; +/** Which browser this bundle was built for. A constant at build time, so the + * branch the other browser needs is dropped from the bundle entirely. */ +export const isFirefox = typeof __TARGET__ === 'string' && __TARGET__ === 'firefox'; +/** Where the reader manages installed extensions, for messages that name it. */ +export const extensionsPage = isFirefox ? 'about:debugging' : 'chrome://extensions'; diff --git a/src/common/messages.ts b/src/common/messages.ts index bd8f1db..6ddb067 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -119,6 +119,18 @@ const Envelope = Schema.Union( Schema.Struct({ ok: Schema.Literal(false), error: Schema.String }), ); +/** Whether the extension this content script came from is gone — reloaded, + * updated or removed — leaving the script running on a page it may no longer + * touch. Chrome empties `runtime.id`; Firefox throws when it is read. Reading + * it must not itself throw, because the callers are failure handlers. */ +export function orphaned() { + try { + return !chrome.runtime?.id; + } catch { + return true; + } +} + /** Chrome and Promise interop lives at this boundary; callers get validated data. */ export function request(message: T): Promise> { return Effect.runPromise( diff --git a/src/common/settings.ts b/src/common/settings.ts index b53498d..6a69f34 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -131,6 +131,32 @@ export const providers = { custom: { label: 'Custom (OpenAI-compatible)', baseUrl: '' }, } satisfies Record; +/** The pages Sharp filters. Declared in the manifest, but Firefox treats + * host permissions as optional even when they are declared, so the popup has + * to be able to ask for them. */ +export const siteOrigins = [ + 'https://x.com/*', + 'https://twitter.com/*', + 'https://www.youtube.com/*', +] as const; + +/** The origin the configured provider is reached at, if it has a usable one. A + * half-typed custom URL has none, and is not worth an error here: saving + * validates it properly. */ +export function providerOrigins( + settings: Pick, +): readonly string[] { + const base = + settings.provider === 'custom' ? settings.customBaseUrl : providers[settings.provider].baseUrl; + if (!base) return []; + try { + const url = new URL(base); + return url.protocol === 'https:' ? [`${url.origin}/*`] : []; + } catch { + return []; + } +} + export function publicSettings(settings: Settings): PublicSettings { const { apiKeys, ...rest } = settings; return { diff --git a/src/popup/App.tsx b/src/popup/App.tsx index c78ac11..da10048 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'preact/hooks'; import { Schema } from 'effect'; import { request, type Stats } from '../common/messages'; import { threadId } from '../common/post'; -import { Settings, type SettingsPatch } from '../common/settings'; +import { providerOrigins, Settings, siteOrigins, type SettingsPatch } from '../common/settings'; +import { extensionsPage } from '../common/build'; import { GeneralPanel, type GeneralTab } from './GeneralPanel'; import { ListSheet, type ListKey } from './ListSheet'; import { ModelBrowser } from './ModelBrowser'; @@ -14,6 +15,9 @@ import { Notice } from './ui'; type Form = { draft: Settings; saved: Settings }; type Overlay = { kind: 'model'; view: 'models' | 'providers' } | { kind: 'list'; list: ListKey }; const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const noop = () => {}; +/** `https://x.com/*` reads as x.com to the person being asked about it. */ +const host = (origin: string) => origin.replace(/^https:\/\//, '').replace(/\/\*$/, ''); function normalize(settings: Settings): Settings { const lines = (values: readonly string[], author = false) => [ @@ -44,6 +48,7 @@ export function App() { const [thread, setThread] = useState(''); const [site, setSite] = useState(null); const [busy, setBusy] = useState(false); + const [missing, setMissing] = useState([]); /** Keep an open popup honest about edits made from the timeline, without * discarding fields the reader is still editing here. */ @@ -98,6 +103,36 @@ export function App() { return () => chrome.storage.onChanged.removeListener(onChange); }, []); + const provider = form?.saved.provider; + const customBaseUrl = form?.saved.customBaseUrl; + /** Chrome grants declared host permissions at install, so this finds nothing + * and the banner never appears. Firefox hands them out one origin at a time, + * and without them the content scripts never run and the provider is + * unreachable, which otherwise looks exactly like a broken extension. */ + useEffect(() => { + if (provider === undefined || customBaseUrl === undefined) return; + let live = true; + const wanted = [...siteOrigins, ...providerOrigins({ provider, customBaseUrl })]; + void Promise.all( + wanted.map(async (origin) => + (await chrome.permissions.contains({ origins: [origin] })) ? null : origin, + ), + ).then((results) => { + if (live) setMissing(results.filter((origin) => origin !== null)); + }, noop); + return () => { + live = false; + }; + }, [provider, customBaseUrl]); + + /** Asking must be the first thing the click does: Firefox rejects a + * permission request that is not still inside the user's gesture. */ + function grant() { + void chrome.permissions.request({ origins: [...missing] }).then((granted) => { + if (granted) setMissing([]); + }, noop); + } + async function run(action: () => Promise) { setBusy(true); setMessage(undefined); @@ -185,7 +220,7 @@ export function App() { (key) => typeof patch[key] !== 'object' && result[key] !== patch[key], ); if (dropped.length) { - throw new Error('Sharp was updated. Reload it on chrome://extensions, then save again.'); + throw new Error(`Sharp was updated. Reload it on ${extensionsPage}, then save again.`); } setForm({ draft: result, saved: result }); setMessage({ text: 'Saved.', tone: 'good' }); @@ -263,8 +298,21 @@ export function App() {
- {(status || message?.tone === 'bad') && ( + {(missing.length > 0 || status || message?.tone === 'bad') && (
+ {missing.length > 0 && ( + <> + + {`Sharp has no access to ${missing.map(host).join(', ')} yet, so it cannot filter there.`} + +
+ +
+

Reload any open tab afterwards.

+ + )} {status && {status}} {message?.tone === 'bad' && {message.text}}
diff --git a/src/popup/GeneralPanel.tsx b/src/popup/GeneralPanel.tsx index a5f9d0f..a95c147 100644 --- a/src/popup/GeneralPanel.tsx +++ b/src/popup/GeneralPanel.tsx @@ -141,7 +141,7 @@ export function GeneralPanel({ {settings.provider === 'custom' ? ( this.settle(selected, results, generation), () => { - if (!chrome.runtime.id) this.stop(); + if (orphaned()) this.stop(); this.settle(selected, [], generation); }, ) diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..60c6569 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises'; +import { expect, it } from 'vitest'; +// @ts-expect-error -- a build script, deliberately outside the TypeScript project. +import { assetsOf, GECKO_ID, GECKO_MIN_VERSION, manifestFor } from '../scripts/manifest.mjs'; +import { defaults, providerOrigins, siteOrigins } from '../src/common/settings'; + +const base = JSON.parse(await readFile('manifest.json', 'utf8')); + +it('leaves Chrome the manifest in the repository, untouched', () => { + expect(manifestFor(base, 'chrome')).toBe(base); +}); + +it('gives Firefox an event page, an add-on ID and a floor it can actually run', () => { + const firefox = manifestFor(base, 'firefox'); + // A service worker key would make Firefox refuse the add-on outright. + expect(firefox.background).toEqual({ scripts: ['background.js'], type: 'module' }); + expect('service_worker' in firefox.background).toBe(false); + expect(firefox.browser_specific_settings.gecko).toEqual({ + id: GECKO_ID, + strict_min_version: GECKO_MIN_VERSION, + // AMO refuses a new add-on without this, and what it says has to match + // privacy-policy.md: nothing to the developer, post content to the + // provider the reader chose. + data_collection_permissions: { required: ['websiteContent'] }, + }); + // 133 for storage.local.getBytesInUse, 140 for data_collection_permissions. + expect(Number.parseFloat(GECKO_MIN_VERSION)).toBeGreaterThanOrEqual(140); + expect(firefox.content_scripts).toEqual(base.content_scripts); + expect(firefox.permissions).toEqual(base.permissions); +}); + +it('packages the same files whichever key names the background script', () => { + const chrome = assetsOf(base); + expect(assetsOf(manifestFor(base, 'firefox'))).toEqual(chrome); + expect(chrome).toContain('background.js'); + expect(chrome).toContain('manifest.json'); + expect(chrome).toContain('wire.js'); + expect(chrome).toContain('content.css'); + expect(new Set(chrome).size).toBe(chrome.length); +}); + +it('asks for exactly the origins the manifest declares for the filtered sites', () => { + for (const origin of siteOrigins) expect(base.host_permissions).toContain(origin); +}); + +it('derives a provider origin, and none from a URL that cannot be one', () => { + expect(providerOrigins(defaults)).toEqual(['https://openrouter.ai/*']); + expect(providerOrigins({ ...defaults, provider: 'anthropic' })).toEqual([ + 'https://api.anthropic.com/*', + ]); + for (const origin of providerOrigins({ ...defaults, provider: 'openai' })) { + expect(base.host_permissions).toContain(origin); + } + expect(providerOrigins({ provider: 'custom', customBaseUrl: 'https://example.com/v1' })).toEqual([ + 'https://example.com/*', + ]); + expect(providerOrigins({ provider: 'custom', customBaseUrl: '' })).toEqual([]); + expect(providerOrigins({ provider: 'custom', customBaseUrl: 'not a url' })).toEqual([]); + // http is refused on save, so it must not be requested either. + expect(providerOrigins({ provider: 'custom', customBaseUrl: 'http://example.com' })).toEqual([]); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts index a6bfa86..0412f44 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { request, Request } from '../src/common/messages'; +import { orphaned, request, Request } from '../src/common/messages'; import { Schema } from 'effect'; import { defaults } from '../src/common/settings'; import { mockChrome } from './chrome'; @@ -136,3 +136,20 @@ it('fills settings from an older worker with defaults instead of failing', async chrome.runtime.sendMessage.mockResolvedValue({ ok: true, result: { ...stale, enabled: 'yes' } }); await expect(request({ type: 'GET_SETTINGS' })).rejects.toThrow(); }); + +it('reports an orphaned content script however the browser signals it', () => { + mockChrome(); + expect(orphaned()).toBe(false); + // Chrome empties runtime.id once the extension is reloaded or removed. + vi.stubGlobal('chrome', { runtime: {} }); + expect(orphaned()).toBe(true); + vi.stubGlobal('chrome', {}); + expect(orphaned()).toBe(true); + // Firefox throws instead, and this runs inside failure handlers. + vi.stubGlobal('chrome', { + get runtime(): never { + throw new Error('Extension context invalidated.'); + }, + }); + expect(orphaned()).toBe(true); +});