Repository navigation
Architecture
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.
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.
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:
-
idandname, the browser as users know it, for messages such as "Firefox can't encode video on this computer". -
capabilities:systemAudio,tabCaptureandscreenPickerNeedsGesture. Chrome has the first two and not the third; Firefox is the other way round. -
screen.captureScreen, the screen picker. Chrome usesdesktopCapture(chrome.ts) and Firefox usesgetDisplayMedia(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.
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.onMessagehandlesopenAppandscreenshotmessages from the popup. -
action.onClickedasks the recorder to stop. The browser fires it only while the popup is switched off, which happens during a countdown or a recording. -
windows.onRemovedforgets the app window and resets the toolbar when you close that window. -
runtime.onInstalledopens the home page (app.html#/home) in a new tab after an install, or after an update that changes the version number. -
runtime.onStartupresets 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.
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.
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.
openAppin the background worker opensapp.htmlin a 960 × 780 window of typepopup, centred over the browser window you last focused. Only one exists at a time: the worker stores its window id inbrowser.storage.sessionunderappWindowId, and a secondopenAppfocuses that window and changes its hash instead of opening another. -
Normal tabs. Screenshots, the home page and the popup's footer links open
app.htmlin 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.
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 tohashchangethroughuseSyncExternalStoreand returns the hash without its#, or/when empty. -
navigate(path)setswindow.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.
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.
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
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 byrecording. -
screenshots: one row per screenshot. -
uploads: one row per upload to a service, indexed bysource([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.
Using Screen Tools
- Installation and Updating
- Firefox
- Recording
- Screenshots
- Uploading
- Settings
- Troubleshooting and FAQ
- Privacy
How it works
Contributing