Skip to content

Architecture

Tom Bouquet edited this page Nov 2, 2026 · 2 revisions

This page describes how Screen Tools is put together: its three extension entrypoints, how they find and talk to each other, how the Chrome and Firefox builds differ, and where each kind of data lives. Read it before changing anything that crosses between the toolbar popup, the background worker and the app page. The capture and upload flows have their own pages: Recording Pipeline, Screenshot Pipeline and Upload Pipeline. Source Layout lists every file.

Entrypoints

Screen Tools is a Manifest V3 extension for Chrome and Firefox, built with WXT. WXT reads srcDir: 'src' from wxt.config.ts and turns each folder or file under src/entrypoints/ into one part of the manifest. The config sets manifestVersion: 3 for both browsers, because WXT builds Firefox as MV2 by default, and MV2 has no action API.

Entrypoint Source Built as Runs
Background worker src/entrypoints/background.ts Service worker in Chrome, background script in Firefox Window management, toolbar clicks mid-recording, screenshots
Toolbar popup src/entrypoints/popup/ popup.html The menu shown when you click the toolbar icon
App page src/entrypoints/app/ app.html The recorder, video and screenshot lists, editor, player, settings, uploads

The manifest function in wxt.config.ts builds the manifest per browser:

Chrome Firefox
Permissions desktopCapture, storage, notifications, activeTab, scripting The same without desktopCapture
Minimum version Chrome 116 (minimum_chrome_version) Firefox 140 (browser_specific_settings.gecko.strict_min_version)
Settings link options_page set to app.html#/settings options_ui with open_in_tab, since Firefox ignores options_page
Extension ID From key, when WXT_EXTENSION_KEY is set screen-tools@pipelabs.com.au

Both builds also declare identity as an optional permission, and the upload services' API origins plus http://*/* and https://*/* as optional host permissions. None of them is asked for at install. The Upload Pipeline page covers when they are.

All extension code calls the browser object from wxt/browser, never chrome, so the same code runs in both browsers.

Browser differences

What differs between Chrome and Firefox lives in src/platform/. Shared code imports platform from @/platform and asks it, instead of checking which browser it's in. index.ts picks the implementation from import.meta.env.FIREFOX, which WXT sets at build time, so each build bundles only its own.

The Platform interface in types.ts has:

  • id and name, the browser as users know it, for messages such as "Firefox can't encode video on this computer".
  • capabilities: systemAudio, tabCapture and screenPickerNeedsGesture. Chrome has the first two and not the third; Firefox is the other way round.
  • screen.captureScreen, the screen picker. Chrome uses desktopCapture (chrome.ts) and Firefox uses getDisplayMedia (firefox.ts). The Recording Pipeline page covers both.
  • copy: the text that names the browser, such as the blocked-screenshot notification and the pinning hint on the home page.

The pages use the capabilities to hide what a browser can't do. Without systemAudio, the recorder hides the System and System + mic audio choices, and getSettings() turns a stored choice that includes system audio into Mic or None. Without tabCapture, the recorder and home page say "your whole screen or a window" instead of mentioning tabs. The Firefox page and docs/firefox.md list the differences users see.

Background worker

The background worker keeps nothing in memory between events; the one value it needs later, the recorder window id, goes in browser.storage.session. It registers five listeners in background.ts:

  • runtime.onMessage handles openApp and screenshot messages from the popup.
  • action.onClicked asks the recorder to stop. The browser fires it only while the popup is switched off, which happens during a countdown or a recording.
  • windows.onRemoved forgets the app window and resets the toolbar when you close that window.
  • runtime.onInstalled opens the home page (app.html#/home) in a new tab after an install, or after an update that changes the version number.
  • runtime.onStartup resets the toolbar, in case the browser quit mid-recording.

The worker takes screenshots itself, because a screenshot has to outlive the popup that asked for it. The Screenshot Pipeline page covers that flow. It doesn't run uploads: Chrome stops an idle service worker, so uploads run in the app page.

Toolbar popup

The popup (src/pages/popup.tsx) offers three record buttons, three screenshot buttons and links to the app. Every action sends a message to the background worker and closes the popup, because the browser destroys the popup page as soon as it loses focus. The footer links to Latest videos, Screenshots and Settings open the app in a normal tab instead, through openAppTab in download.ts.

App page

The app page is a single React tree rooted at App in src/app.tsx. The same app.html opens in two kinds of window:

  • The recorder window. openApp in the background worker opens app.html in a 960 × 780 window of type popup, centred over the browser window you last focused. Only one exists at a time: the worker stores its window id in browser.storage.session under appWindowId, and a second openApp focuses that window and changes its hash instead of opening another.
  • Normal tabs. Screenshots, the home page and the popup's footer links open app.html in a tab. A tab can show camera and microphone permission prompts, which the recorder window doesn't always show, so the recorder's "Fix in new tab" action opens Settings in a tab.

Recording runs in the app page rather than the worker, because it needs DOM APIs the service worker lacks: <video> elements, a canvas with captureStream, AudioContext and MediaRecorder. Uploads run there too, in whichever window started them.

Routing

The app page routes on the URL hash, so a link such as app.html#/settings opens a route directly. src/hooks/use-route.ts holds the whole router:

  • useRoute() subscribes to hashchange through useSyncExternalStore and returns the hash without its #, or / when empty.
  • navigate(path) sets window.location.hash. Changing only the hash never reloads the page.

Page in app.tsx maps the path to a page component, after stripping any ?query. The pages are /videos and /videos/<id>, /screenshots and /screenshots/<id>, /player, /settings, /changelog and /home. Any other path renders the recorder. The screenshot editor route (/screenshots/<id>) switches the layout to fill the window, since the editor sizes its canvas to the space left over.

The recorder reads one query itself: /?start=<source>, where <source> is screen, camera or screen-camera. The popup's record buttons send this route, and the recorder sets the capture source and navigates back to /. In Chrome it also starts recording straight away. Firefox only opens its screen picker from a click in the page (screenPickerNeedsGesture), so there the recorder only picks the source and waits for you to click Start recording, unless the source is the camera alone. If a finished recording is still unsaved, the recorder shows "Save or discard this recording first" and doesn't start.

While a recording is starting, counting down, running or stopping, the recorder calls setBusy(true) from use-busy.ts, and the header disables its navigation buttons.

Message passing

All messages between extension pages go through browser.runtime.sendMessage, and their shapes are the AppMessage union in src/lib/messages.ts.

Message Sender Receiver Effect
openApp Popup Background worker Opens or focuses the recorder window at route
screenshot Popup Background worker Takes a screenshot of tabId in windowId
stopRecording Background worker Recorder page Stops the recording; the recorder replies true

The screenshot message carries maxWidth from the screenshot quality setting, because the worker can't read settings. Settings live in the localStorage of extension pages (settings.ts), and a service worker has no localStorage. A maxWidth of 0 keeps the native size.

Stopping from the toolbar icon

During a countdown or a recording, toolbar.ts sets the action popup to an empty string, so a click on the icon fires action.onClicked in the worker instead of opening the menu. The worker sends stopRecording, and the recorder's listener in recorder.tsx stops the session and replies true. If no page replies, for example because the recorder window has gone, the worker puts the menu popup back with resetToolbar() and calls browser.action.openPopup(). That call needs Chrome 127, or a Firefox that allows it there; otherwise the next click opens the menu.

flowchart LR
  user((You))
  popup["Toolbar popup<br/>popup.html"]
  worker["Background worker<br/>background.ts"]
  win["Recorder window<br/>app.html, route /"]
  tab["App tab<br/>app.html, route /screenshots/id"]
  page["Captured web page"]
  idb[("IndexedDB<br/>screen-tools")]
  ls[("localStorage<br/>settings")]
  conn[("storage.local<br/>connections")]
  cloud["Upload services<br/>Drive, Dropbox, servers"]

  user -- "click icon" --> popup
  user -- "click icon mid-recording" --> worker
  popup -- "openApp" --> worker
  popup -- "screenshot" --> worker
  popup -. "reads quality" .-> ls
  worker -- "windows.create / focus" --> win
  worker -- "stopRecording" --> win
  worker -- "executeScript, captureVisibleTab" --> page
  worker -- "addScreenshot" --> idb
  worker -- "tabs.create" --> tab
  win -- "recording chunks" --> idb
  win -. "reads" .-> ls
  tab -- "edits" --> idb
  win -- "uploads" --> cloud
  tab -- "uploads" --> cloud
  win -. "tokens" .-> conn
  tab -. "tokens" .-> conn
Loading

Storage

Screen Tools keeps everything on the computer it runs on, in four places. Files only leave it when you upload them.

Store Holds Read and written by
IndexedDB database screen-tools Recordings, their chunks, screenshots, upload records db.ts, from the app page and the worker
localStorage key settings User preferences as JSON settings.ts, from extension pages only
browser.storage.local key connections Connected accounts and their tokens, and saved upload servers connections.ts, from extension pages
browser.storage.session key appWindowId The recorder window's id The background worker

The database is at version 2 and has four object stores with auto-increment keys:

  • recordings: one row per video, with its date, MIME type, duration, saved and seekable flags, the name it was saved as, and a thumbnail.
  • chunks: the video data, indexed by recording.
  • screenshots: one row per screenshot.
  • uploads: one row per upload to a service, indexed by source ([sourceKind, sourceId]). Version 2 added this store.

The upgrade callback only adds stores, so data from an older version is kept. The database keeps the latest 5 recordings and the latest 10 screenshots, and deletes older ones as new ones arrive, along with their upload rows. Pruning skips a recording that still has an upload running.

getSettings() validates every stored field and falls back to the default for anything missing or unknown, so an old or hand-edited value can't break a page. Pages subscribe through useSettings(), which listens for the settings-change event within a page and for storage events from other extension pages, such as an options tab.

Clone this wiki locally