chrome-plugin-manga-translator

Architecture notes

Contracts in this codebase that are easy to break by accident, and the reasoning behind them. Read this before touching the listed areas.

Config storage round-trip

src/stores/config-v2.ts is the only persisted configuration store. Its storage adapter is asymmetric on purpose:

Direction Transform
getItem (rehydrate) deobfuscateAllApiKeys
setItem (persist) obfuscateAllApiKeys

Consequences that have caused real bugs:

Regression coverage: src/stores/config-storage-sync.test.ts.

Who may translate without being asked

Three settings are easy to conflate, and conflating them once caused the extension to translate every site the user visited:

Setting Meaning
enabled master switch; gates everything, including manual actions
autoTranslateHosts hosts where a page translates on load, unprompted
autoContinueEnabled within an already-translated page, keep translating images that load later

isTranslationEnabled returns the master switch only. Use shouldAutoTranslatePage(config, url) to decide whether to act unprompted; isAutoContinueEnabled covers the in-page case. Background gates tabs.onUpdated and the content script’s READY handler with the allowlist, and the content script arms auto-continue only after the user has requested the page (or the host is allowlisted).

The allowlist is empty by default. Keep it that way: <all_urls> plus unattended operation is the combination that sends a user’s banking or webmail images to a third-party endpoint, and it is what Chrome Web Store review scrutinises.

Regression coverage: src/background/auto-translate.test.ts.

Content script size

The content script is injected into every page the user opens, so its weight is paid on page load. scripts/check-release-consistency.mjs fails the build if dist/content.js exceeds 200 KB.

Two things keep it small, both easy to undo by accident:

  1. react-vendor and react-dom-vendor must stay separate chunks in vite.config.ts. The content script reaches React transitively through Zustand but never uses ReactDOM. A single ['react', 'react-dom'] entry made the post-build rebundler inline ~110 KB of unused DOM renderer.
  2. contentScriptRebundler() must keep minify: true. It inlines chunks after Vite’s per-chunk minification and re-expands them otherwise.

Regression coverage: the size budget itself, plus pnpm build in CI.

Background image proxy

src/background/image-fetch-guard.ts exists because the content script cannot read cross-origin image bytes itself (the canvas taints), so the service worker fetches on its behalf. That makes the worker a fetcher driven by page content.

Layers, in order:

  1. isImageFetchAllowedForSender in background.ts — extension pages are refused; cross-origin is limited to image-shaped URLs.
  2. isSafeImageUrl — http(s) only, and a private-address blocklist.
  3. fetchImageBytes — credentials: 'omit', a 20s timeout, a 12 MB cap, and an image/* content-type requirement.

Do not remove the sender check on the grounds that the URL check “already covers it”. DNS rebinding defeats hostname checks, and the sender check is what stops a page from using the extension as a general-purpose fetch proxy.

bytesToBase64 is a table encoder rather than btoa(String.fromCharCode(...)): the string-building step was quadratic for multi-megabyte webtoon strips, and host behaviour around latin1 strings varies.

Regression coverage: src/background/image-fetch-guard.test.ts.

Two message envelopes

background.ts dispatches two shapes and they are not interchangeable:

Unifying them is desirable but is a migration, not a rename: every consumer must move in the same change.

Service worker lifetime

MV3 terminates an idle service worker after roughly 30 seconds. A chapter of tiles can keep the queue busy for minutes, so background.ts registers an alarms listener while translation is enabled, and the job map is capped at 500 terminal records.

Be precise about what the alarm is and is not. chrome.alarms does not hold a worker open — it schedules wake-ups for a worker that has already been restarted, and its job here is to re-read the concurrency limit from storage so a worker that came back mid-chapter does not run with a stale one. What keeps in-flight content-script requests reachable is the outstanding sendMessage response channel, together with Chrome replaying events to the restarted worker. The alarm period is 0.5 minutes, Chrome’s documented floor: a smaller value is silently clamped, so an authored 0.34 would never have delivered the ~20s cadence it was written to describe.

Tesseract core selection

text-detector.ts passes an explicit corePath ending in .js. tesseract.js uses such a path verbatim instead of selecting a variant by feature detection, which means: