--- name: developing-web-clipper description: Use when working on Trilium's browser extension, the web clipper (`apps/web-clipper`, built with WXT for Chrome MV3 and Firefox MV2) — its background script, content script (Readability, screenshots, toasts), the Preact popup and options page, `wxt.config.ts` (manifest, keyboard `commands`, the Firefox sources zip), or the server side it talks to (`apps/server/src/routes/api/clipper.ts`, `/api/login/token`, the desktop port). Covers the message protocol between the extension's parts, how it finds and authenticates to Trilium, the compatibility rule with older Trilium versions, styling the pages in the Next theme from the client's own stylesheets, running and building it, the `fakeBrowser` spec patterns and the 100% coverage gate, and the traps already hit (undeclared commands, image placeholders, `$&` in `replaceAll()` replacements, `javascript:` links, the sources zip). --- # Developing the web clipper `apps/web-clipper` is a browser extension built with [WXT](https://wxt.dev) (0.21, on Vite 8). One source tree produces two builds: **Chrome, Manifest V3** (`pnpm build`, `.output/chrome-mv3`) and **Firefox, Manifest V2** (`pnpm build:firefox`, `.output/firefox-mv2`). There is no Safari or Edge target. The manifest is generated from `wxt.config.ts`; there is no `manifest.json` in the repository. ## The parts and how they talk | Entrypoint | Runs in | Does | |---|---|---| | `entrypoints/background/index.ts` | service worker (MV3) / background page (MV2) | context menus, keyboard commands, every save, image download, toasts | | `entrypoints/background/trilium_server_facade.ts` | background | finds Trilium, authenticates, `callService()` | | `entrypoints/content/index.ts` | every page (``) | Readability extraction, selection, crop overlay, toast UI | | `entrypoints/popup/main.tsx` | the toolbar popup | Preact `Popup`: capture buttons, link-with-note form, connection status | | `entrypoints/options/main.tsx` | the options page | Preact `Options`: desktop port, server login | | `entrypoints/offscreen/` | MV3 only (Chrome) | crops screenshots on a canvas, since an MV3 service worker has no DOM | Everything goes through `browser.runtime.sendMessage` / `browser.tabs.sendMessage`, keyed by a `name` field: - **popup → background:** `save-cropped-screenshot`, `save-whole-screenshot`, `save-whole-page`, `save-link-with-note` (`title`, `content`), `save-tabs`, `trigger-trilium-search`, `send-trilium-search-status`, `trigger-trilium-search-note-url`, `openNoteInTrilium` (`noteId`), `closeTabs`. - **background → popup:** `trilium-search-status` (`triliumSearch: TriliumSearchStatus`, which starts as `searching`) and `trilium-previously-visited` (`searchNote`). Both types are exported from `trilium_server_facade.ts`; the popup imports them. - **background → content script:** `trilium-save-selection`, `trilium-save-page`, `trilium-get-rectangle-for-screenshot` (the content script answers with the payload), and `toast` (`message`, `noteId`, `tabIds`). - **background → offscreen:** `{ type: "CROP_IMAGE" }`, the one message keyed by `type`. Every user action in the background runs inside `showFailures()`, which turns an exception into a toast. A new action belongs inside it too, or it fails silently. A toast cannot show on browser pages or the extension stores, where no content script runs. ## Finding and authenticating to Trilium - **Desktop first.** `getPort()` returns the port from the options page, or **37840** (production) / **37743** (development build, the port `pnpm desktop:start` uses). It tries that one port — there is no port scan — with `GET http://127.0.0.1:/api/clipper/handshake`, then falls back to the configured server. It repeats every 60 seconds, and on the popup's **check** button. - **Version check.** The handshake returns `protocolVersion` (`CLIPPER_PROTOCOL_VERSION` in `packages/trilium-core/src/services/app_info.ts`); the extension compares the major version and reports `version-mismatch`. - **Desktop needs no auth**; the routes rely on the loopback bind and the Host-header check in `apps/server/src/services/desktop_network_gate.ts`. **A server needs an ETAPI token**: the options page posts the password and the optional TOTP code to `POST /api/login/token`, which creates a token named "Trilium Sender / Web Clipper", stored in `browser.storage.sync`. - **Server routes** are in `apps/server/src/routes/routes.ts` (`/api/clipper/*`) with the handlers in `apps/server/src/routes/api/clipper.ts` (spec: `clipper.spec.ts`). They are **server-only**, not in `packages/trilium-core`, so the standalone and mobile builds cannot receive clippings. - The dev port must match `TRILIUM_PORT` in `apps/desktop/package.json`'s `dev` script; when one changes, change `getPort()` and the Developer Guide's *Web Clipper* page with it. ## Compatibility: the extension outlives the server version Users update the extension and Trilium independently, so **a change on one side must keep working against older versions of the other.** Prefer changes that need nothing new from the server. - `/api/login/token` has verified TOTP since v0.99.0, but only recent servers report which factor failed (`{ message, factor }`); older ones answer a 401 with a plain string. That is why the options page always shows the optional code field and sends it on the first try, and treats the `factor` only as a hint for the error message (`requestToken()` catches the JSON parse failure). Asking for the code only after a reported failure would have locked out every older 2FA server. - The image-failure fix put a failed image's original URL back into the content instead of adding a server field, so the server's existing `downloadImages()` retries it on every version. - `/api/sender/login` (Trilium Sender) shares the token handler; keep its 401 status unchanged. - A server-side change reaches beyond the clipper spec: `apps/server/src/routes/transport.spec.ts` used `/api/login/token` to check plain-text tuple results, and the JSON body broke it. Grep the server specs for the route (`grep -rn "api/login/token" apps/server/src apps/server/spec`) and run every hit, not only the route's own spec. ## Content and images - The content script replaces each `` with a random **20-character placeholder** (`randomString(20)`) and lists the images; the background fetches each one into a data URL; the server stores them as attachments and rewrites the placeholder. `downloadImages()` in core skips 20-character URLs on purpose, so **an image left with its placeholder is a broken image**. `postProcessImages()` therefore restores the original URL (escaped with `escapeHtml()`) for every image it cannot download and reports the count in the toast. - **A `replaceAll()`/`replace()` replacement built from page data is a callback** (`replaceAll(id, () => escapeHtml(src))`). A string replacement reads `$&`, `$$`, `` $` `` and `$'` as patterns, so an image URL containing `$&` came back with the placeholder inside it. - **HTML built by concatenation escapes every page-derived value** with `escapeHtml()` — the save-tabs list once put tab titles in raw. Tabs can also lack a `url` (still loading), and `new URL("")` throws, so `saveTabs()` filters them out. - `fetchImage()` rejects a non-OK response and a non-image `Content-Type` — otherwise an error page gets saved as the "image". - Readability is `@mozilla/readability`, installed from npm; Firefox review flags its `innerHTML` use, which the README explains to the reviewer. ## The popup and options page: Preact in the Next theme Both pages are Preact 11 (`preact`, `preact/hooks`), with JSX compiled by `oxc.jsx` in `wxt.config.ts` and `jsx`/`jsxImportSource` in `tsconfig.json`. Each `index.html` is only a `#root` and a `