openpencil/packages/op-chrome-extension/background.js
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

390 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Service worker: owns the element-pick flow, which cannot live in the popup.
*
* Chrome closes a popup the moment the user clicks into the page, and every
* `chrome.*` call from a torn-down popup stops settling. Picking an element
* *requires* that click, so the popup can only start the flow — arming the
* picker, capturing the chosen subtree, delivering it, and reporting the
* outcome all have to happen somewhere that outlives it. That is here.
*
* Because there is no UI left to report into, the outcome is delivered two
* ways, both of which the user sees without being asked to keep anything
* open:
*
* * a transient badge on the toolbar icon — `✓` or `✗`, coloured;
* * a stored result line the popup renders the next time it opens, at which
* point the badge is cleared.
*
* The delivery mode is not asked for again either: the pick reuses whichever
* of "send" or "download" the user last chose in the popup, persisted under
* `lastAction`, and — for a send — whichever destination is stored under
* `deliveryTarget`. One button, one predictable behaviour.
*
* # The account destination, from here
*
* Uploading to the hub needs the session's CSRF token, and that token lives in
* POPUP memory by design: it is never written to storage, so there is nothing
* here to read. The worker therefore obtains its own by running the same
* session probe the popup runs (`account.js`'s `fetchSession`), which also
* settles the question that matters more — whether the session is still live
* at the moment of the pick, minutes or hours after the popup last looked.
*
* That is why `account.js` is imported here rather than duplicated: it depends
* on nothing but `core-registry.js`, so pulling it into the worker's graph
* adds no dynamic `import()` (which `ServiceWorkerGlobalScope` forbids, see
* `scripts/check-sw-imports.mjs`). The popup's `wasm-core.js` loader stays out
* of that graph, as it always has.
*/
import { deliveryTarget, fetchSession, hubOrigin, storedRegion } from './account.js';
import { capturePage } from './capture.js';
import { importSnapshot, sendSnapshotToAccount } from './client.js';
import {
coreError,
getCore,
hasCore,
invalidateCore,
isCoreTrap,
setCore,
} from './core-registry.js';
import { initLocale, t } from './i18n.js';
import { clearPick, pickElement } from './picker.js';
// STATIC import, and it has to be. `import()` is disallowed on
// `ServiceWorkerGlobalScope` by the HTML specification (w3c/ServiceWorker
// issue 1356) — not merely after startup, but at all: Chrome rejects the call
// itself, whenever it is made. So the worker cannot use the popup's
// `wasm-core.js` loader, and must name the shim at parse time instead. The
// path is fixed at build time (`scripts/build-wasm.sh` writes exactly this
// file), so there is nothing to resolve dynamically.
//
// The consequence is deliberate: in a checkout where `wasm/` has not been
// built, this import fails and the worker does not register at all. That is
// the honest outcome — the extension is unbuilt — and the popup, which
// imports none of this graph, still comes up on its own and says which script
// to run. `scripts/check-sw-imports.mjs` fails the build if a dynamic
// `import(` ever appears in this file or anything it reaches.
import initCore, * as coreExports from './wasm/op_chrome_extension_core.js';
const ENDPOINT_KEY = 'endpoint';
const LAST_ACTION_KEY = 'lastAction';
const PICK_RESULT_KEY = 'pickResult';
/** The `.wasm` beside the shim, addressed absolutely for the worker. */
const CORE_WASM = 'wasm/op_chrome_extension_core_bg.wasm';
/** In-flight initialization, so concurrent wakes share one instantiation. */
let initializing = null;
/**
* Initialize the statically imported core. Idempotent.
*
* Only the *instantiation* is asynchronous here, and async work is fine in a
* worker — it is dynamic `import()` alone that is banned. The `.wasm` URL is
* passed explicitly rather than left to the shim's `import.meta.url` default,
* so the fetch is unambiguous no matter how the worker was started.
*
* A worker is evicted when idle and re-evaluated on the next event, so this
* runs again per wake; it costs a fetch from local disk plus a compile.
*/
function ensureCore() {
if (hasCore()) return Promise.resolve(getCore());
if (!initializing) {
initializing = (async () => {
await initCore({ module_or_path: chrome.runtime.getURL(CORE_WASM) });
return setCore(coreExports);
})().catch((cause) => {
initializing = null;
// The shim resolved (a static import that did not resolve would have
// stopped the worker registering), so this can only be an
// initialization failure — never "the extension has not been built".
throw coreError('wasmInit', cause);
});
}
return initializing;
}
/** How long the ✓ / ✗ badge stays up when nobody opens the popup. */
const BADGE_MS = 90_000;
const BADGE = {
ok: { text: '✓', color: '#128a63' },
error: { text: '✗', color: '#c0392b' },
};
/**
* Record the outcome for the next popup, and flash it on the toolbar icon.
*
* The message is stored as a key plus substitutions rather than as rendered
* text: the popup renders it, so a language change between the pick and the
* next popup open still shows the right language. A substitution may itself
* be a `{ key, args }` reference — `statusPicked` nests the delivery message
* that way rather than rendering it here, which is what keeps this whole
* record language-free.
*
* The badge is exempt: `✓` / `✗` need no locale.
*/
async function report(tone, key, args) {
await chrome.storage.local.set({
[PICK_RESULT_KEY]: { tone, key, args: args || [], at: Date.now() },
});
const badge = BADGE[tone];
await chrome.action.setBadgeBackgroundColor({ color: badge.color });
await chrome.action.setBadgeText({ text: badge.text });
// A badge that never expires would still be there tomorrow, describing a
// capture the user has long since forgotten.
setTimeout(() => {
chrome.action.setBadgeText({ text: '' }).catch(() => undefined);
}, BADGE_MS);
}
/**
* Turn snapshot text into something `chrome.downloads` can fetch.
*
* MV3 service workers do not expose `URL.createObjectURL`, so the popup's
* blob-URL route is unavailable here and the bytes have to travel inside the
* URL itself. Base64 costs 4/3; percent-encoding would cost nearer 3× on
* JSON, which is all quotes and braces.
*/
function dataUrl(text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
// `String.fromCharCode(...bytes)` blows the argument limit on anything
// large, so build the binary string in bounded slices.
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(index, index + 0x8000));
}
return `data:application/json;base64,${btoa(binary)}`;
}
/**
* Resolve the account destination for a send, or null when this is not one.
*
* Two calls to the core's rule, with the hub probe between them. The first is
* a cheap pre-check — `deliveryTarget(true)` answers "account" only when the
* user actually stored that choice — so a user who never touched the setting
* never pays for a network round trip on a pick. The second re-asks with what
* the probe found, which is where an expired session collapses back to the
* local editor, in the core, exactly as it does in the popup.
*
* @returns {Promise<{origin: string, csrfToken?: string, fellBack?: boolean} | null>}
*/
async function accountDelivery() {
if ((await deliveryTarget(true)) !== 'account') return null;
const origin = await hubOrigin(await storedRegion());
let view;
try {
view = await fetchSession(origin);
} catch {
// An unreachable hub is not a reason to lose the capture: it resolves the
// same way an expired session does, and the report says where it went.
view = { state: 'error' };
}
if ((await deliveryTarget(view.state === 'signedIn')) !== 'account') {
return { origin, fellBack: true };
}
return { origin, csrfToken: view.csrfToken };
}
async function deliver(mode, text, meta, endpoint, account) {
if (mode === 'download') {
// Same conversion as the popup: hand back a ready-to-open `.op` document,
// not the raw snapshot. An empty capture surfaces as an actionable error
// rather than a broken file.
const converted = JSON.parse(getCore().snapshotToOpDocument(text, String(meta.title || '')));
if (!converted.ok) {
const error = new Error(String(converted.error || 'empty capture'));
error.code = 'empty';
throw error;
}
const filename = getCore().opFilename(String(meta.title || ''));
try {
await chrome.downloads.download({ url: dataUrl(converted.op), filename, saveAs: false });
} catch (cause) {
const error = new Error(String((cause && cause.message) || cause));
error.code = 'download';
throw error;
}
return { key: 'statusDownloaded', args: [filename] };
}
if (account && account.csrfToken) {
const filed = await sendSnapshotToAccount(account.origin, account.csrfToken, text, meta);
return { key: 'statusSentToAccount', args: [filed.name] };
}
const outcome = await importSnapshot(endpoint, text);
// A pick the user aimed at their account that ended up here went local
// because the session was gone. Saying so is the difference between a
// surprise and an explanation.
return {
key: account && account.fellBack ? 'statusSentLocalFallback' : 'statusImported',
args: [String(outcome.nodeCount)],
};
}
/**
* Map a thrown error onto the same message keys the popup uses, so a failure
* reads identically whether it happened in the popup or out here.
*/
function failureMessage(error, endpoint) {
const code = error && error.code;
const detail = String((error && (error.detail || error.message)) || error);
// `forbidden` means two different things depending on where the capture was
// going — "this editor has no extension ingress" locally, "origin or CSRF"
// at the hub — so the hub's failures are routed by the tag `client.js` puts
// on them rather than by the code alone.
if (error && error.account) return accountFailureMessage(code, detail, error);
switch (code) {
case 'cancelled':
return { key: 'statusPickCancelled', args: [] };
case 'restricted':
return { key: 'errorRestrictedTab', args: [] };
case 'noTab':
return { key: 'errorNoTab', args: [] };
case 'empty':
return { key: 'errorNoSnapshot', args: [] };
case 'chunkLost':
return { key: 'errorChunkLost', args: [] };
case 'offline':
return { key: 'errorOffline', args: [endpoint] };
case 'timeout':
return { key: 'errorTimeout', args: [endpoint, detail] };
case 'tooLarge':
return { key: 'errorTooLarge', args: [detail] };
case 'forbidden':
return { key: 'errorNoIngress', args: [detail] };
case 'import':
return { key: 'errorImport', args: [detail] };
case 'download':
return { key: 'errorDownload', args: [detail] };
case 'wasmInit':
case 'wasmMissing':
// Never `errorCoreMissing` from here. The worker only runs at all when
// its static import of the shim resolved, so the build product IS
// present; telling the user to build it would send them to fix
// something that is not broken.
return { key: 'errorCoreInit', args: [detail] };
default:
return { key: 'errorInjection', args: [detail] };
}
}
/**
* The same mapping for a failed upload to the account.
*
* Deliberately the message set the popup uses for these codes, so the outcome
* of a pick reads exactly like the outcome of the button.
*/
function accountFailureMessage(code, detail, error) {
switch (code) {
case 'signedOut':
return { key: 'errorAccountSignedOut', args: [] };
case 'forbidden':
return { key: 'errorAccountForbidden', args: [detail] };
case 'quota':
return {
key: 'errorAccountQuota',
args: [String(getCore().hubQuotaItems()), String(getCore().hubQuotaTotalMb()), detail],
};
case 'rateLimited':
return accountRateLimitMessage(error.retryAfterSeconds, detail);
case 'tooLarge':
return { key: 'errorTooLarge', args: [detail] };
case 'rejected':
return { key: 'errorAccountRejected', args: [detail] };
case 'unavailable':
return { key: 'errorAccountUnavailable', args: [detail] };
case 'hubOffline':
case 'hubTimeout':
case 'hubNotPermitted':
return { key: 'accountUnavailable', args: [detail] };
default:
return { key: 'errorAccountUnavailable', args: [detail] };
}
}
/** "Try again in about N minutes", or seconds, or without a number. */
function accountRateLimitMessage(seconds, detail) {
if (typeof seconds !== 'number') return { key: 'errorAccountRateLimited', args: [detail] };
if (seconds < 60) return { key: 'errorAccountRateLimitedSeconds', args: [String(seconds)] };
return { key: 'errorAccountRateLimitedMinutes', args: [String(Math.ceil(seconds / 60))] };
}
/**
* Keep the worker alive across the pick.
*
* Everything else in the flow is a steady stream of `chrome.*` calls, each of
* which resets the idle timer. Waiting for the user to click is the one gap
* that can run for minutes with no API traffic at all, and an evicted worker
* would drop the pick silently — the overlay would stay up over a page whose
* click goes nowhere.
*/
function keepAlive() {
const timer = setInterval(() => {
chrome.runtime.getPlatformInfo().catch(() => undefined);
}, 20_000);
return () => clearInterval(timer);
}
async function runPick(tabId) {
// The overlay's two lines are the only text this worker renders itself, and
// they go onto the user's page — so they follow the language chosen in the
// popup, not the browser's. A worker is re-evaluated after each eviction,
// hence the load here rather than at module scope.
await initLocale();
const stored = await chrome.storage.local.get([ENDPOINT_KEY, LAST_ACTION_KEY]);
const endpoint = stored[ENDPOINT_KEY] || getCore().defaultEndpoint();
const mode = stored[LAST_ACTION_KEY] === 'download' ? 'download' : 'send';
const stopKeepAlive = keepAlive();
let picked;
try {
picked = await pickElement(tabId, {
hint: t('pickHint'),
cancel: t('pickCancelHint'),
});
} finally {
stopKeepAlive();
}
const { text, meta } = await capturePage(tabId, undefined, { pickedRoot: true });
const account = mode === 'send' ? await accountDelivery() : null;
// An upload to the hub can run for a minute or more with no `chrome.*` call
// in it, which is exactly the gap that gets a worker evicted — the same
// reason the pick itself is wrapped.
const stopDeliveryKeepAlive = keepAlive();
let delivered;
try {
delivered = await deliver(mode, text, meta, endpoint, account);
} finally {
stopDeliveryKeepAlive();
}
await report('ok', 'statusPicked', [
`${picked.tag} ${picked.width}×${picked.height}`,
{ key: delivered.key, args: delivered.args },
]);
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (!message || message.type !== 'pick') return undefined;
const tabId = message.tabId;
// The popup is about to close; acknowledge before it does so it can.
sendResponse({ ok: true });
(async () => {
try {
await ensureCore();
await runPick(tabId);
} catch (error) {
if (isCoreTrap(error)) invalidateCore();
await clearPick(tabId).catch(() => undefined);
const stored = await chrome.storage.local.get(ENDPOINT_KEY);
const failure = failureMessage(error, stored[ENDPOINT_KEY] || '');
// A cancel is the user's own decision, not a failure to flag in red.
const tone = failure.key === 'statusPickCancelled' ? 'ok' : 'error';
await report(tone, failure.key, failure.args);
}
})();
// Nothing further is sent, but keeping the channel nominally open costs
// nothing and documents that the work outlives this handler.
return false;
});