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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ coverage/
/popup.js
/popup.css
/popup.html
/firefox/
.worktree-backup/
.env
.env.*
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ bun.lock
/popup.css
/popup.html
.worktree-backup
/firefox
87 changes: 82 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```

Expand All @@ -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.
Expand All @@ -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-<version>-chrome.zip` and
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`sharp-<version>-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
Expand All @@ -72,7 +149,7 @@ src/
youtube/ Toggle-driven page rules: three attributes on <html>, 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
Expand Down
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions privacy-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 49 additions & 27 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -23,48 +45,48 @@ 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
// nothing shared with the rest of the extension beyond pure helpers.
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()));
Expand Down
55 changes: 55 additions & 0 deletions scripts/manifest.mjs
Original file line number Diff line number Diff line change
@@ -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',
]),
];
}
Loading
Loading