openpencil/packages/op-chrome-extension/README.md
Kayshen-X bc1f35ef9b feat(web): chrome extension offline download emits a ready-to-open .op file
The download fallback used to save the raw capture snapshot JSON, which
OpenPencil cannot open directly. Route the snapshot through op-html's
import_snapshot_document in the wasm core so the extension hands back a
canonical .op document (with node count reported and empty captures
surfaced as an actionable error instead of a broken file).
2026-08-05 22:15:09 +08:00

43 KiB
Raw Permalink Blame History

OpenPencil Web Capture (Chrome extension)

Capture the rendered state of any web page and import it into OpenPencil as editable design nodes.

The extension is deliberately thin: it runs the canonical OpenPencil DOM extractor — crates/op-html/assets/snapshot-extractor.js, the same script the op CLI and the MCP import_web_snapshot tool consume — inside the active tab and hands the resulting JSON to a running OpenPencil. Because the extractor reads getComputedStyle + getBoundingClientRect, JS-heavy pages capture the way they actually look, not the way their HTML source reads.

Manifest V3, no dependencies, no external network access. The extension's logic is Rust compiled to WebAssembly; the JavaScript is a thin layer over the chrome.* APIs (see Architecture).

Install (unpacked)

  1. Build the logic core — the extension will not run without it:

    packages/op-chrome-extension/scripts/build-wasm.sh
    
  2. chrome://extensions → enable Developer mode (top right).

  3. Load unpacked → select this directory (packages/op-chrome-extension).

  4. Pin the extension so its icon is visible in the toolbar.

If you edit a .js file, hit Reload on the extension card. If you edit anything under crates/op-chrome-extension-core/, re-run the build script first — the popup says so explicitly if the core is missing or stale-broken.

Requires Chrome 103+ (the first release that accepts 'wasm-unsafe-eval' in an extension page's CSP).

Architecture

Every rule the extension applies without asking the browser lives in Rust, in crates/op-chrome-extension-core, and is unit-tested on the host target:

cargo test -p op-chrome-extension-core
In Rust (crates/op-chrome-extension-core/src/) Why
endpoint.rs — endpoint parsing, loopback whitelist, localhost127.0.0.1 The outbound-destination guard. Testable against a table of hostile inputs.
transfer.rs — chunk planning, slice integrity, truncated detection, size cap The rules that decide whether a capture is intact.
ingress.rs — the /mcp tools/call envelope template + reply classification The wire shape (and its key order) is pinned by a test; reply shapes are parsed once, in one place.
filename.rs — download-name sanitisation The page title is attacker-controlled.
account.rs — regions, hub origins, hub URLs, session-reply parsing The reachable host set and everything rendered from another user's profile. See Account.
delivery.rs — where a capture is allowed to go One rule, one place: an expired session or a target the hub does not answer collapses to the local editor.
hub.rs — the account inbox's create envelope, name, size ceiling The Hub's contract, field for field, including the ceilings that decide whether a request is worth sending at all.
hub_reply.rs — what each inbox status means Nine statuses, nine pieces of advice. Classified once, against fixtures copied from op-hub's own tests.
js_text.rs — JS-compatible whitespace + UTF-16 measurement Keeps the port behaviourally identical to the JS it replaced.
In JavaScript Why
popup.js chrome.tabs / downloads / storage and the popup DOM.
popup-status.js The status surface and the failure-code → message mapping, which grows with every destination while the controller does not. No chrome.* at all.
i18n.js The message layer: loads _locales/<locale>/messages.json and substitutes $1-style slots. Not chrome.i18n — see Languages.
background.js Service worker. Owns the element-pick flow, which outlives the popup (see Capture element).
client.js fetch, AbortController, reading the response body — plus the one JSON.stringify that embeds the snapshot in the /mcp envelope, for the reason below. Also the account upload.
account.js The hub arm: fetch with credentials: 'include', chrome.tabs for the sign-in tab, chrome.permissions for the pre-flight check, and the storage of the row the header paints.
capture.js chrome.scripting plus the functions injected into the tab — those are evaluated in the page's process and must be JS. Slices are joined here because only JS can rejoin a split surrogate pair exactly.
picker.js The hover/click overlay, likewise injected into the page. Pure DOM work, no rules to share.
core-registry.js The one live core instance, and nothing else. Imports nothing — see below.
wasm-core.js The popup's loader. Dynamic import, so an unbuilt checkout still renders an actionable error instead of a blank window.
vendor/snapshot-extractor.js The canonical extractor, a verbatim copy of the Rust asset (see below). Never edited.

Why the core has two loaders. import() is disallowed on ServiceWorkerGlobalScope by the HTML specification (w3c/ServiceWorker#1356) — not merely after startup, but at all; Chrome rejects the call itself. So the two entry points cannot load the core the same way:

Entry point Strategy If wasm/ is missing
popup.jswasm-core.js await import('./wasm/…'), caught Popup opens and says which script to run.
background.js import initCore, * as coreExports from './wasm/…' at the top of the module The worker does not register. Chrome flags it on the extension card.

capture.js and client.js are used from both, so they depend on neither strategy: they read the instance from core-registry.js, which imports nothing at all. That is also what keeps wasm-core.js — the one file with a dynamic import — out of the worker's static graph. scripts/check-sw-imports.mjs walks that graph and fails on any dynamic import( in it; it runs as step 7 of build-wasm.sh and in bun run lint.

The worker's initCore is passed an explicit chrome.runtime.getURL(...) for the .wasm rather than relying on the shim's import.meta.url default, so the fetch is unambiguous however the worker was started. Asynchronous work is fine in a worker — it is dynamic import() alone that is banned.

The two failures also get different messages, because they call for different fixes: errorCoreMissing ("this extension has not been built") only ever comes from the popup's failed dynamic import, and a core that is present but will not start reports errorCoreInit instead. A pick attempted while the worker is not registered reports errorPickUnavailable rather than blaming the tab.

Why the /mcp request body is assembled in JS. The core owns the envelope's shape — it exports it as a template with a single placeholder, and a Rust test pins the exact bytes, including key order. Only the splice is JavaScript's, and it has to be: JSON.stringify is the one escaper that round-trips a lone surrogate (as \udXXX), which pages do put in titles and alt text and which a Rust &str cannot represent at all — passing the snapshot into wasm rewrote every one of them to U+FFFD. Splicing outside wasm also keeps a 32 MB capture from being copied into and back out of the wasm heap.

wasm/ holds the build product (op_chrome_extension_core.js + op_chrome_extension_core_bg.wasm) and is gitignored, matching how the repo treats every other wasm-bindgen output (crates/op-host-web/pkg/, packages/op-web-sdk/wasm/).

The manifest declares content_security_policy.extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' https: data:". The 'wasm-unsafe-eval' keyword is what MV3 requires to instantiate a bundled .wasm; it permits WebAssembly.compile on package bytes and nothing else. No code is fetched from anywhere but this package — the build script fails if the generated shim contains an eval, a new Function, or an http(s):// reference.

img-src is stated rather than left to the default, which is unrestricted: the account row renders an avatar from the hub's CDN, and writing the directive down turns "any image at all" into "package images, HTTPS images, and the one data: URI in popup.css (the select chevron — a fetched background image would be a remote request)". A data: URI cannot execute under this policy; script-src is unchanged.

Use

  1. Open the page you want to capture and let it finish rendering (lazy images, fonts, and animations included — the extractor snapshots what is on screen).
  2. Click the extension icon.
  3. Choose one of the three actions:
Action What it does
Capture full page Captures the whole page and sends it to the OpenPencil instance at the endpoint under OpenPencil endpoint (default 127.0.0.1:3100).
Capture element Lets you point at one element and captures only its subtree. See below.
Download .op Captures and saves a ready-to-open <page-title>.op document instead of sending it — double-click it to open in OpenPencil.

The status area reports node counts, importer warnings, and whether the page hit the extractor's 20,000-node cap. The endpoint setting is collapsed by default — open it only if OpenPencil is not on 127.0.0.1:3100.

Capture element

Click Capture element and the popup closes; the page you were on picks up a hover outline with the element's tag and pixel size, and a banner across the top telling you what to do. Click to capture that element's subtree, or press Esc to cancel. The outline is drawn on top of the page — nothing on the element itself is touched — and it is removed on selection, cancel, navigation, or after two minutes of inactivity.

The popup cannot stay open across that click: Chrome closes a popup the moment it loses focus, and every chrome.* call from a closed popup stops settling. So the flow is owned by the extension's service worker, and the result comes back two ways:

  • a badge on the toolbar icon — green or red , cleared after 90 seconds or as soon as you open the popup;
  • a detail line in the popup's status area the next time you open it, naming the element and what happened to it.

The capture is delivered the same way as whichever of "Capture full page" or "Download .op" you used last — the choice is remembered in chrome.storage.local under lastAction, and defaults to sending. There is no fourth button and no extra prompt: the element pick reuses a decision you have already made.

The subtree lands in OpenPencil as one frame at the document origin, with its descendants positioned relative to it — the same shape a full-page capture produces, so nothing downstream has to know which button you pressed. If the element you click is itself an <img> / <svg> / <canvas> / <video>, the extractor wraps it in a frame of the same size, because the importer reads snapshot.root as a container.

Languages

The popup ships in the product's full 15 locales — the same set, in the same order, as the editor's own picker (crates/op-i18n/src/locale.rs):

English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

The language is yours to choose, not the browser's. Open the settings row at the bottom of the popup and pick one; the choice is stored in chrome.storage.local under uiLocale and every string re-renders in place — no reload, no reopening. The default, before you have chosen anything, is whatever chrome.i18n.getUILanguage() maps onto (zh-TW → 繁體中文, pt-BR → Português, anything unsupported → English).

That switcher is why the extension does not call chrome.i18n.getMessage. That API only ever answers in the browser's UI language — there is no getMessage(key, subs, locale) overload — so a user running Chrome in English could never read this popup in 日本語. i18n.js reads the same _locales/<locale>/messages.json files Chrome itself consumes (one catalog, not two) and does the $1-style substitution in JS, matching chrome.i18n's semantics: positional $1$9, named $placeholder$ resolved through the entry's placeholders block, $$ for a literal $, and a per-key fallback to English so one missing string never drags a whole locale back to English.

One thing the switcher cannot reach: the extension's name and description — what you see in the toolbar tooltip, on the chrome://extensions card, and in a store listing. Those come from the manifest's __MSG_extName__ / __MSG_extDescription__, which Chrome resolves against the browser's language before any extension code runs. Nothing an extension can do changes that, so those two strings follow Chrome while everything inside the popup follows you.

The element-picker overlay — the hover banner drawn on your page — is rendered by the service worker, and it honours the chosen language too. The / badge needs no locale. A pick that finishes while the popup is closed is stored as message keys plus their substitutions rather than as rendered text, so switching language between the pick and the next popup open still reads right.

scripts/check-locales.mjs (part of bun run lint, alongside the extractor and service-worker guards) fails on any drift: a key present in one catalog and not another, a message whose $n slots do not match English's, an empty string, invalid JSON, a _locales/ directory with no row in the switcher or the reverse, or a manifest __MSG_* key that some locale cannot resolve.

The three delivery paths

Which one the Capture button uses is decided by the delivery.rs rule: path 1 unless you are signed in AND have chosen your account under Send to, in which case path 3. Download .op is always path 2.

1. Send to a running OpenPencil (the button)

The extension POSTs the snapshot to the local editor. It tries, in order:

Ingress Served by Notes
POST /api/import/web-snapshot desktop app's live MCP endpoint (crates/op-host-services/src/mcp_live/snapshot_ingest.rs) Insert-only, no token needed. The one route on that endpoint reachable from a chrome-extension:// origin.
POST /mcptools/call import_web_snapshot headless daemon openpencil-desktop --serve-web <port> Fallback, tried only on a 404. That daemon is unmanaged (permissive CORS, no token) so a plain MCP call works.

The desktop route is the recommended path, and the only one this extension is designed around. The fallback exists for the unmanaged --serve-web daemon, whose /mcp is an open local surface: it takes no token and answers permissive CORS, so anything running on your machine that can reach that port can drive it. That is pre-existing behaviour of that daemon, not something the extension adds — if it matters to you, run the desktop app instead of the bare daemon. The fallback is not a compatibility path for older desktop builds: a desktop app that predates the ingest route answers 403 (not 404), so the extension reports "no extension ingress" rather than retrying.

The desktop app does not listen by default. Turn the server on in Settings → MCP → server (default port 3100), or launch via op start, which forces it on for that run. ~/.openpencil/.op-mcp-port records the port the editor actually bound if you changed it.

The endpoint box accepts loopback only — 127.0.0.1:<port>, [::1]:<port>, or localhost:<port>, which is rewritten to 127.0.0.1 (the live endpoint refuses Host headers carrying a DNS name — that is what DNS-rebinding attacks supply). Anything else is refused before a request is made, so a capture of the page you are on can never be posted to a remote host.

Which extensions the desktop route accepts

The ingest route accepts a chrome-extension://<id> origin, and by default any well-formed one: this extension is unpublished, so an unpacked load derives a different id on every machine and there is no stable id to pin. An accepted origin buys exactly one insert-only tool, and the reply's Access-Control-Allow-Origin names that one origin (never *), so no other extension can read the answer.

To lock the route to specific ids, start the editor with:

OPENPENCIL_EXTENSION_ALLOWED_IDS=abcdefghijklmnopabcdefghijklmnop openpencil-desktop

(comma-separated, ids only, no chrome-extension:// prefix). With it set, every other extension origin is refused. Your unpacked extension's id is on its card in chrome://extensions.

2. Download and open by hand

Download .op writes a ready-to-open .op document (OpenPencil's PenDocument format — the same conversion op import:snapshot performs, run in the browser). Open it directly:

  • double-click the <page-title>.op file, or
  • drag it onto the app window, or
  • op open ~/Downloads/example.op.

No CLI conversion step is needed — the file is already a .op document.

3. Send to your OpenPencil account

POST <hub>/api/v1/snapshots, with the hub's session cookie and the session's X-CSRF-Token. See Delivering to your account below for what that costs, what it refuses, and how long it keeps a capture.

Your OpenPencil account (optional)

Everything above works signed out, and always will. Signing in adds an identity to the popup's header and — once the hub grows a snapshot inbox — a second place a capture can go. It is never required, and it changes nothing about the local import path.

Signing in

Press Sign in in the popup header. A normal browser tab opens on the OpenPencil Hub's own sign-in page (GET /api/v1/auth/login?return_to=/account) and the whole SSO handshake happens there, between your browser and the hub. Finish it, then reopen the popup: the header now shows your avatar, your display name, a region badge, and Sign out.

The extension is a public client and holds nothing worth stealing:

  • It never sees your password, an authorization code, or a long-lived token.
  • The hub's op_hub_session cookie is HttpOnly and lives in the browser's cookie jar under the hub's origin. The extension cannot read it — and does not declare the cookies permission that would let it try.
  • All it does is GET /api/v1/session with credentials: 'include' and render the reply. The reply's primary_email, roles and capabilities are dropped at the Rust boundary; the popup shows who you are, not what you may do, and what is never handed over cannot leak into a cached row.
  • The session's csrf_token is held in memory for the lifetime of one popup and is never written to storage.

Regions

Two hubs, matching the product's own built-in dual-region design:

Region Hub
cn https://op.zseven.cn
global https://op.zseven.tech

Pick one under Region in the settings row. Both are declared in host_permissions, and account.rs is compiled against exactly those two — a test asserts the manifest and the core agree, because a mismatch would show up as an unexplained network failure rather than as a build error. Changing the region clears the cached account row, since the two are separate deployments with separate accounts.

For hub development there is one escape hatch: set hubDevOrigin in chrome.storage.local to a loopback origin (http://127.0.0.1:18081, the hub's own documented dev invocation) and it wins over the region. Anything that is not loopback is ignored rather than obeyed — the manifest already grants loopback, so the override needs no new permission and cannot aim a cookie-bearing request at a host of someone else's choosing.

Delivering to your account

When you are signed in, a Send to row appears in the settings with two options: this computer's OpenPencil (the default), and your account. Choosing your account makes the Capture button — and the next element pick — upload to the hub's per-user snapshot inbox instead of to loopback. The choice is remembered.

POST <hub>/api/v1/snapshots
Content-Type: application/json
X-CSRF-Token: <the live session's token>
Cookie: op_hub_session=…            (attached by the browser, never read by us)

{ "kind": "web-snapshot", "name": "Example page — 2026-08-04 17:20",
  "source_url": "https://example.com/pricing",
  "captured_at": "2026-08-04T09:20:31Z", "snapshot": { … } }

Everything about that request that is a decision lives in the core: the envelope and the page-title-derived name in hub.rs, and what each answer means in hub_reply.rs. Three consequences are worth knowing before you pick this destination:

Ceiling Value What you see
Per capture 32 MB, same as the local route Refused before the upload starts, with the Download .op advice.
Per account 50 snapshots or 200 MB "Your account inbox is full", naming both ceilings.
Per hour 20 uploads "Try again in about N minutes", from the hub's own Retry-After.
Retention 30 days An unclaimed capture is deleted; the inbox is not storage.

The name is derived from the page title, sanitised the same way a download filename is, plus a local-time stamp — so it is readable in the account portal and cannot disguise itself there.

A session that expired sends the capture to the local editor instead. That is delivery.rs's single rule, and it applies identically to the button and to an element pick that finishes minutes later; the status line says which destination it went to.

The source_url is sent when the page has an ordinary http(s) URL. It is the one field that turns an inbox into a partial browsing history, so anything unusual — credentials in the URL, a non-http scheme, characters a browser would not have produced — is withheld rather than uploaded.

The API was specified from this side first, in docs/hub-inbox-api-proposal.md; op-hub shipped it, and that document now records what was asked for and what landed.

Signing out

POST /api/v1/auth/logout with the session's CSRF header. This used to be answered 403 — the hub required an Origin exactly equal to its own, and an extension page always sends Origin: chrome-extension://<id>, which fetch does not let any client change. op-hub now admits a well-formed extension origin on exactly two routes, sign-out and the snapshot inbox (auth.ExtensionCapableMutation), so the one-click sign-out works.

The popup clears its local copy either way — it has stopped believing in the session regardless of what the hub says — and falls back to opening the hub's account page if the call is still refused, which is what happens against an older hub or one that pins a different extension id.

If the account row insists you are signed out

The design depends on one browser behaviour that is not visible in either repository's source: Chrome treats an extension-initiated request to a host in host_permissions as same-site for cookie purposes, which is what makes the hub's SameSite=Lax session cookie travel with the probe. If a future Chrome tightened that, every probe would return 401 while you are plainly signed in on the hub in another tab. That is the first thing to check.

(The absence of Access-Control-Allow-Origin on hub responses is not a problem, and not worth chasing: a fetch from an extension page to a host the extension has permission for is a privileged request — no preflight, no ACAO required. It is the same mechanism the loopback import path relies on.)

Permissions, and why each is needed

Permission Why
activeTab Run the extractor in the tab you clicked the button on — granted per click, no standing access to your browsing.
scripting The injection API itself, for the extractor, the transfer harness and the picker overlay.
downloads The Download .op fallback.
storage Remembers the endpoint you typed, the delivery you last used, one pending pick result, and the account row.
host_permissions: http://127.0.0.1/* POST the snapshot to your local OpenPencil.
host_permissions: https://op.zseven.cn/* Ask the China hub who is signed in, and open its sign-in page. Used only if you sign in.
host_permissions: https://op.zseven.tech/* The same, for the Global hub.

There is deliberately no <all_urls> host permission: activeTab already covers capture, and the only hosts the extension can address are loopback and the two hubs above.

There is also no cookies permission. The hub session cookie is HttpOnly and belongs to the hub's origin; the extension reads the session through GET /api/v1/session instead, which is both narrower and the documented contract. An extension that asks for cookies to reach one site's session has asked for every site's.

http://localhost/* is not declared, and would be dead weight if it were: the core rewrites localhost to 127.0.0.1 before any URL is built (the live endpoint refuses a Host header carrying a DNS name), so no request is ever addressed to it.

[::1] is a different story. Chrome's match-pattern grammar has no syntax for an IPv6 host — http://[::1]/* is rejected outright and would make the whole manifest fail to load — so an [::1]:<port> endpoint cannot be covered by host_permissions. The endpoint box still accepts it, and it still works when the editor answers the extension's origin with CORS headers, but it is the one endpoint form that depends on the server's CORS reply rather than on a granted host permission. Prefer 127.0.0.1:<port>.

The bundled extractor is a verbatim copy

vendor/snapshot-extractor.js must stay byte-identical to crates/op-html/assets/snapshot-extractor.js. Chrome's "Load unpacked" reads the directory as plain files, so a symlink would not survive — hence a copy plus a drift check:

scripts/check-extractor-sync.sh          # verify (exit 1 on drift, prints a diff)
scripts/check-extractor-sync.sh --fix    # re-copy the canonical asset

Run the check after touching anything under crates/op-html/assets/.

The extractor is a console-oriented IIFE: it returns nothing, copies its output to the clipboard, and triggers a snapshot.json download. capture.js runs it between two tiny harness injections that patch URL.createObjectURL, HTMLAnchorElement.prototype.click and navigator.clipboard.writeText in the extension's isolated world only (the page never sees the patches), grabs the Blob the extractor builds, and restores the originals. The extractor file itself is never edited or wrapped from this side — changes go to the Rust asset, and the copy is refreshed with --fix.

Its IIFE takes one optional argument, read off globalThis.openpencilSnapshotOptions: { root: <element> } narrows the capture to that element's subtree. With the global unset — which is how the script behaves when pasted into a devtools console, and how capture.js leaves it for a full-page capture — it captures document.body and emits byte-for-byte what it always did. The picker sets the global in the isolated world just before the extractor runs; capture.js clears it afterwards, so a pick can never leak into the next full-page capture.

Large pages

A snapshot embeds rasterized images (up to 24 MB) and can reach tens of MB, which is more than one chrome.scripting.executeScript return value should carry across a process boundary. So the capture is transferred in 4 MiB slices: the harness parks the JSON string on a global in the tab's isolated world, and the popup pulls it back slice by slice, verifying the reassembled length before using it. A capture is never silently truncated — a lost or short slice is reported as an error. (The extractor's own 20,000-node cap is separate, is set by the Rust asset, and is surfaced in the status area when it trips.)

Files

manifest.json                    MV3 manifest
popup.html / popup.css / popup.js  Popup UI, light + dark, 15 locales with an in-popup switcher
popup-status.js                  The popup's status line + every failure-to-sentence mapping
background.js                    Service worker: the element-pick flow, badge, stored result
capture.js                       Injection harness + chunked transfer
picker.js                        Hover/click element picker injected into the page
client.js                        The two local ingresses + the account upload (fetch only)
account.js                       The hub arm: session probe, sign-in tab, cached account row
core-registry.js                 The one live core instance (imports nothing)
i18n.js                          Message loader + $1 substitution + the locale list
wasm-core.js                     The popup's dynamic loader for the core
wasm/                            Build product of scripts/build-wasm.sh (gitignored)
dist/                            Store archives from scripts/package-extension.sh (gitignored)
vendor/snapshot-extractor.js     Verbatim copy of the Rust asset (do not edit)
scripts/build-wasm.sh            Builds crates/op-chrome-extension-core → wasm/
scripts/package-extension.sh     Builds, verifies and zips a store submission
scripts/check-extractor-sync.sh  Drift check for the extractor copy
scripts/check-sw-imports.mjs     Fails if the service worker graph has a dynamic import()
scripts/check-locales.mjs        Fails on key / placeholder drift across the 15 catalogs
_locales/<locale>/messages.json  UI strings — 15 locales, en is default_locale
icons/                           Toolbar icons, derived from the desktop app's flat mark
docs/privacy-policy.md           The listing's privacy policy, EN + 中文
docs/hub-inbox-api-proposal.md   The account-delivery API, as proposed to op-hub (now shipped)

The icons are the flat cyan mark from crates/op-host-desktop/assets/icon.png (a 1024px master that is exactly two colours: the mark over a white plate), with the plate un-composited away, the mark cropped to its own bounds plus 10% padding, and the result box-filtered down to 16 / 48 / 128. Dropping the plate is what makes the mark legible at 16px and keeps it readable on both a light and a dark toolbar; the gradient app-icon it replaced is not used anywhere in the extension.

Version

manifest.json carries the product version, and tools/check-version-sync.sh fails if it drifts from [workspace.package].version in the root Cargo.toml. scripts/sync-version.sh does not rewrite it — the file is oxfmt-formatted and the version writer would reflow it — so a version bump means editing the "version" field here by hand. The check names the expected value.

Publishing to the Chrome Web Store

Build the archive

packages/op-chrome-extension/scripts/package-extension.sh
# or, from packages/:  bun run package-extension

It builds the wasm core, runs cargo test -p op-chrome-extension-core, node --checks every runtime script, runs the three drift guards (extractor, service-worker imports, 15 locales), stages only the runtime files, and writes dist/op-chrome-extension-<version>.zip with the manifest at the archive root. Scripts, docs, .gitignore and the crate's pkg/ never enter it. The listing at the end of the run is what the store will receive.

The script fails rather than shipping something surprising: a root .js that is not in its RUNTIME_FILES list, a missing build product, or a _locales/ that does not hold exactly 15 catalogs all stop it.

The pt caveat

The popup resolves its own catalogs, so pt already serves every Portuguese user inside the popup. The extension name and description are different: Chrome resolves those __MSG_* placeholders itself, against the browser's UI language, and a store listing is matched on the exact _locales/ directory. So the packaging script copies pt to pt_BR and pt_PT at package time.

They are generated rather than committed on purpose: a committed copy would be a third catalog to keep in sync, and check-locales.mjs would then have to either police three identical files or exempt them. The switcher still offers 15 languages — pt_BR and pt_PT are resolvable, never selectable.

Fill in the listing

Field What to use
Name / description Come from _locales/*/messages.json (extName, extDescription) — do not retype them.
Category Developer Tools.
Privacy policy URL Publish docs/privacy-policy.md and link it. It is written to be published verbatim.
Single purpose "Capture the rendered state of a web page and import it into the user's OpenPencil as editable design nodes."
activeTab + scripting "Runs the OpenPencil DOM extractor in the tab the user pressed the button on."
downloads "Saves the capture as a ready-to-open .op document when the user chooses Download .op."
storage "Stores the user's endpoint, language, region, chosen destination and account display name locally."
http://127.0.0.1/* "Delivers the capture to the OpenPencil application running on the user's own computer."
The two op.zseven.* "Reads the signed-in user's own account profile from the OpenPencil Hub, and uploads a capture to that same account when the user selects it as the destination. Optional; the extension is fully functional signed out."
Data usage disclosures No data collected for analytics, advertising, or sale. Website content is handled only on user action and sent only to the user's own destination.

Expect review to ask about the host permissions. The answer is in Permissions: no <all_urls>, no cookies, and three named hosts, one of which is loopback.

After the first publish: pin the id

The extension's id is only stable after it is published (an unpacked load derives it from the directory path, so it differs on every machine). Once the store assigns one:

  1. Copy the id from the store listing or from chrome://extensions.

  2. Set it on every OpenPencil that should accept captures from it:

    OPENPENCIL_EXTENSION_ALLOWED_IDS=<id> openpencil-desktop
    

    The editor's ingest route admits any chrome-extension:// origin when this is unset; setting it narrows admission to the ids you name, which is what you want once a published id exists — a side-loaded copy of this extension can no longer post into your editor.

  3. If a second listing is ever published per region, list both ids comma-separated.

The same id is what docs/hub-inbox-api-proposal.md asks op-hub to allowlist so the extension's logout — and, later, its upload — passes the hub's Origin check.

Troubleshooting

"OpenPencil is not reachable at 127.0.0.1:3100" — nothing is listening. Start the desktop app and switch on Settings → MCP → server, run op start, or run openpencil-desktop --serve-web 3100. Check the port with cat ~/.openpencil/.op-mcp-port.

"This OpenPencil build has no extension ingress" — the app is listening but predates POST /api/import/web-snapshot, and its general /mcp surface refuses browser-extension origins by design. Update the app, or use Download .op and open the file in OpenPencil. You will also see this if the editor was started with OPENPENCIL_EXTENSION_ALLOWED_IDS set to a list that does not include this extension's id.

"This capture is larger than 32 MB" — the snapshot route caps its body at 32 MB (it is the one ingress that needs no token, so it does not get to make the editor buffer an arbitrary amount). Use Download .op and open the file in OpenPencil, which has no such limit.

"… did not answer within 15s" — the connection was accepted but the reply never came. The editor is busy or stuck (a modal dialog blocking its UI thread will do it). Check the app window, then retry.

"Endpoint must be loopback with a port" — only 127.0.0.1, [::1], and localhost are accepted. The extension will not post a page capture anywhere else.

"Chrome does not allow extensions to run on this page"chrome:// pages, the Web Store, other extensions' pages, and PDF viewer tabs are off limits to every extension. Open a normal http(s) page.

"The extractor produced no snapshot" — the page has no visible <body> (or was still blank). Let it render and retry.

Images look grey / placeholder-ish — cross-origin images taint the canvas, so the extractor cannot rasterize them and falls back to the remote URL or a grey placeholder. That is a browser rule, not an OpenPencil limitation.

Nothing happens after a long wait on a huge page — keep the popup open; closing it cancels the capture in flight. (The element pick is the exception: it runs in the service worker precisely so it survives the popup closing.)

The pick outline is stuck on the page — press Esc, or reload the tab. It also removes itself two minutes after it was armed. A pick that ends this way is reported as cancelled, not as a failure.

The badge is up but I did not see what went wrong — open the popup. The badge is only the notification; the sentence is in the status area, and reading it is what clears the badge.

"Element capture is unavailable: the background worker is not running" — the worker statically imports the built core, so it does not register until scripts/build-wasm.sh has run. Run it, then hit Reload on the extension card. chrome://extensions shows the worker's own error next to the card. Capture full page and Download .op are unaffected; they run in the popup.

"The extension is built, but its logic core would not start" — distinct from "has not been built". The .wasm is there and WebAssembly refused it, so it is likely truncated or mismatched with its shim. Re-run scripts/build-wasm.sh (which now clears stale artifacts first) and Reload.