Repository navigation
Upload Pipeline
This page follows an upload from the Connect button in Settings to the link in the toast: connectors, signing in, tokens, running and resuming uploads, and the three kinds of destination. Read it before changing anything in src/lib/connectors/ or the upload components. The Uploading page describes the same features from the user's side. Two repo guides hold the setup detail, and this page links to them instead of repeating them:
- Cloud connections covers setting up the Google and Dropbox OAuth apps, the redirect URLs, the build variables and adding a service.
- Upload servers is the protocol a server has to speak, written for whoever runs one.
An upload passes through these stages:
- Connect. In Settings, Connect asks for the optional permissions, signs in through the browser's web auth flow and stores the account. Adding an upload server asks for its host and stores its settings.
- Choose. The save dialog (videos) or the upload dialog (screenshots) lists the connected destinations. You pick one and, where the service allows, whether to make a share link.
-
Upload.
startUploadwrites a row to theuploadsstore and hands the file to the connector, which talks to the service through afetchthat adds and renews the token. - Finish. The row gets the result and its links. A toast offers Copy link and Open, and the recording or screenshot lists its uploads, with Resume or Retry for the ones that stopped.
Uploads run in the app page, in whichever window started them. They never run in the background worker, because Chrome stops an idle service worker. Closing the dialog stops the upload, and while one runs the page asks before it closes.
Every destination is a Connector, defined in types.ts. Settings, the dialogs and the upload list only know about this interface, so a new service is one connector file plus its OAuth app.
| Field | Holds |
|---|---|
id |
A ConnectorId: local, google-drive, dropbox or server:<id>
|
name, description, icon
|
What Settings and the dialogs show |
accepts |
video, image or both |
hosts |
API origins, requested as optional host permissions on Connect |
auth |
An OAuthConfig, or null for destinations without a sign-in |
links |
always when every upload gets a public link, so there's no share switch. Defaults to optional
|
fetchAccount |
Looks up who signed in, right after sign-in |
upload |
Sends one file and returns an UploadResult
|
revoke |
Revokes a token on Disconnect, best effort |
UploadResult holds the service's file id, an openUrl for the owner, a shareUrl when a public link was asked for and made, and a shareError when it wasn't. A refused share link doesn't fail the upload, because the file is already there.
registry.ts lists the built-in destinations in CONNECTORS, in display order: local ("This computer", a plain download), googleDrive and dropbox. Upload servers come from storage instead. The registry keeps a map of them in step with the stored connections, so getConnector(id) can stay synchronous; loadConnector(id) reads storage first, for a server added a moment ago. A server that was removed in Settings gets a stand-in from removedServer, so its old uploads still have a name and fail cleanly on retry.
auth.ts signs in with browser.identity.launchWebAuthFlow, which works in Chrome, the other Chromium browsers and Firefox, and lets people pick an account. The redirect is always browser.identity.getRedirectURL(): https://<extension-id>.chromiumapp.org/ in Chrome, and an address worked out from the add-on ID in Firefox. There are no client secrets and no server. Each OAuthConfig picks one of two flows:
-
Implicit (Google Drive). The access token comes back in the redirect fragment, with no refresh token. The first sign-in adds
prompt=select_account. Renewal repeats the flow withinteractive: false,prompt=noneand the account's email aslogin_hint. Google answers that with a JavaScript redirect, so in Chrome the call also passesabortOnLoadForNonInteractive: falseand a 20 s timeout. Firefox doesn't know those options, so it gets the call without them. -
PKCE (Dropbox). The flow sends a SHA-256 challenge of a random verifier and gets back an authorisation code, which
postTokenexchanges attokenUrlfor access and refresh tokens.token_access_type=offlineasks Dropbox for the refresh token. Renewal posts the refresh token to the same URL, and keeps the old refresh token if the service doesn't send a new one.
Both flows send a random state and reject a redirect that doesn't return it. Closing the sign-in window or refusing access throws SignInCancelled, which Settings ignores instead of showing an error.
A connector's clientId comes from the build variables WXT_GOOGLE_CLIENT_ID and WXT_DROPBOX_APP_KEY. When it's empty, connect refuses and Settings shows the service as "Not available in this build". Development covers setting them.
Nothing for uploads is asked for at install. wxt.config.ts declares identity in optional_permissions, and every connector's hosts, plus http://*/* and https://*/* for upload servers, in optional_host_permissions. Required permissions would make the browser disable the extension on update until the user accepted them.
connect requests identity and the connector's hosts in one browser.permissions.request call, before it awaits anything else, because the request needs the click's user gesture. A connector's hosts must also appear in optional_host_permissions, or the request fails. Adding an upload server requests only that server's host, as <scheme>://<hostname>/*, which covers every port.
disconnect undoes it: it calls the connector's revoke, deletes the connection, and gives back the host permissions no other connection uses. It removes identity only when no other signed-in service is left.
connections.ts keeps the connections in browser.storage.local under the key connections, one per connector id, so every extension page sees the same sign-ins. Each Connection holds the account (id, email, name), the TokenSet (access token, refresh token, expiry), when it was connected and, for an upload server, its settings. useConnections() and subscribeConnections() follow changes from any page through storage.onChanged.
Connectors never handle tokens. Their upload gets an AuthorizedFetch from authorizedFetch(id), which adds Authorization: Bearer <token> and renews the token when needed:
- It renews a minute before the stored expiry, so an upload doesn't start with a dying token. A token without an
expires_incounts as lasting an hour. - If the service still answers 401, it renews once more and repeats the request.
- Only one renewal runs per connector at a time, shared by every request waiting on it. Before renewing, it rereads storage in case another page has already done it.
- When renewal fails, it throws
ReconnectNeeded, and the UI asks you to connect the service again in Settings.
upload.ts runs uploads. startUpload takes the destination, the file and, for a stored recording or screenshot, its source. The local destination just downloads the file, with no row or progress. Any other destination gets a row in the uploads store with status uploading, and then run:
- Builds an
UploadContextfor the connector: theAuthorizedFetch, anAbortSignalfor Stop,onProgressfor bytes the service has confirmed, andsaveResumeState. - Takes a Web Lock named
screen-tools-upload-<id>for as long as the upload runs, so other windows can tell it's alive. - Calls the connector's
upload. AnUploadErrorwithretryable: trueis tried again up to 3 times, after 1, 2 and 4 seconds. Any other error fails the upload. - On success, sets the row to
donewith its result, and drops the kept file and resume state. A row with no source, such as a file opened in the Player page, is deleted instead, since nothing would list it.
Progress goes to in-memory listeners on every update, for the progress bars, and to the row at most every 2 s. Stopping an upload deletes its row. A failure sets the row to failed with the error message.
Connectors throw UploadError(message, retryable, status) with a message the user can act on. Each service's API wrapper maps its own responses: a dropped connection, a 429 and a 5xx are retryable, while a full drive, a refused name or an admin block aren't. ReconnectNeeded passes through untouched. upload-status.tsx turns either into the text the dialogs and toasts show.
A closed window cuts off its uploads and leaves their rows saying uploading. The first time an app page imports the uploader, markInterrupted checks the held Web Locks and marks every uploading row without one as interrupted. Uploads still running in another window keep their lock and are left alone.
The upload list offers Resume for an interrupted upload and Retry for a failed one:
-
resumereloads the file and passes the connector theresumeStateit saved, so Drive and Dropbox carry on from the last confirmed byte. If the stored file has changed size since, for example because it was made seekable, it starts over. -
retryisresumewith a fresh start.
The file comes from the row's own blob when it has one, otherwise from the recording or screenshot. startUpload keeps a copy in the row only when the bytes aren't already stored: a compressed copy or a re-encoded screenshot. An original recording is read from its chunks.
The uploads store in db.ts arrived with database version 2. Its source index on [sourceKind, sourceId] lists a recording's or screenshot's uploads; rows without a source are left out of it.
| Field | Holds |
|---|---|
sourceKind, sourceId
|
The recording or screenshot, when it's one the app keeps |
connector |
The destination's ConnectorId
|
fileName, mimeType, kind, share
|
The job as started |
size, sentBytes
|
The file size and the bytes the service has confirmed |
status |
uploading, done, failed or interrupted
|
result, error
|
The UploadResult, or why it failed |
resumeState |
The connector's protocol state, for Resume |
blob |
The file as uploaded, until it's done, when it isn't the stored original |
date |
When it started |
Deleting a recording or screenshot deletes its upload rows. pruneRecordings skips a recording that still has an upload running.
The connector asks for the drive.file scope, which only gives access to files and folders the app made. Uploads go to a "Screen Tools" folder, which the connector finds by name or creates; if there's more than one, the oldest wins.
Uploads use Drive's resumable protocol, in google-drive/resumable.ts:
- A
POSTopens a session with the file's name, type, size and folder. Drive returns the session URL inLocation, and the connector saves it as its resume state, with the file name and size. -
PUTrequests send the file in 8 MiB chunks, a multiple of the 256 KiB Drive requires. A308answer'sRangeheader says how far Drive has got. Three308s in a row with no progress fail the attempt as stalled. - A
200or201returns the file's id andwebViewLink.
On Resume, the connector first asks the saved session how far it got, with an empty PUT and Content-Range: bytes */<size>, and carries on from there. A session that has expired (404 or 410) gets one fresh start. Any other non-retryable error clears the resume state, because Drive says the session is spoilt.
A share link is a reader permission for anyone, and the link is the file's webViewLink. A 403 that isn't a rate limit comes back as a shareError saying the account may not allow public links, which is what a Workspace admin block looks like.
The app has App folder access, so it only sees Apps/Screen Tools, and every path is relative to that folder. Files go in with mode: add and autorename: true, so an upload never overwrites an existing file.
-
Up to 8 MiB, a file goes in one
files/uploadrequest, unless it's resuming. -
Larger files use an upload session:
upload_session/startwith the first 8 MiB chunk,append_v2for each next chunk, thenfinishwith an empty body. The session id and confirmed offset are saved as the resume state after every chunk.
When Dropbox says the offset is wrong, the connector jumps to the offset Dropbox gives, up to 5 times. When the session has gone (expired after 48 hours, already closed or never existed), it starts a new one, up to twice. dropbox/api.ts waits for a 429's Retry-After, up to 60 s and 3 times, before it hands the response back. It also escapes non-ASCII characters in the Dropbox-API-Arg header, since headers must be ASCII and file names needn't be.
A share link comes from create_shared_link_with_settings. If the file already has one, the connector reuses it, looking it up with list_shared_links when the error doesn't include it. With a share link, Open goes to the link too; without one, Open previews the file in the app folder on the Dropbox website.
An upload server is any server that hands out an upload URL and a public link. upload-server.ts has both sides:
-
Adding one.
addUploadServerchecks the name and URL, requests the host permission, sends aHEADto the endpoint to check it answers, and stores aConnectionwith the idserver:<random UUID>. The server's name, endpoint and optional access token go in itsserverfield. It has no sign-in, so its token set is a placeholder that never expires. -
Uploading.
uploadServerConnectorbuilds a connector from those settings, withlinks: 'always', so the dialogs show no share switch. Each upload is two requests: aPOSTto the endpoint with the file name, type and size, plusAuthorization: Bearer <token>when one is set, which answers withuploadUrl,publicUrland optionalheaders; then aPUTof the whole file touploadUrl. ThePUTusesXMLHttpRequest, the only way to see upload progress.
These requests use plain fetch, not AuthorizedFetch, since there's no token to renew. Nothing is resumed: a retry asks for a new upload URL and sends the whole file again. The upload servers guide lists which answers stop the upload and which are retried, and the code keeps to it.
| Component | Does |
|---|---|
ConnectionsCard in settings.tsx
|
Connect and Disconnect for each service with an auth config, and who's connected |
upload-servers.tsx |
Adding and removing upload servers in the same card |
save-video.tsx |
The video save dialog's Save to choice, share switch and upload progress, after any compression |
upload-dialog.tsx |
The screenshot upload dialog, the share switch, and useConnectedDestinations()
|
upload-list.tsx |
A recording's or screenshot's uploads, with progress, links, Copy link, Open, Resume and Retry |
upload-status.tsx |
The progress bar, the "Uploaded to …" toast and error wording |
useConnectedDestinations(kind) returns the connected built-in services that accept the file kind, then the upload servers in the order they were added. Both dialogs remember the last destination in the saveDestination setting and the share switch in shareLink, which is on by default. When the remembered destination isn't connected any more, the save dialog falls back to This computer and the screenshot dialog to the first connected one.
Write a connector, add it to CONNECTORS, add its hosts to optional_host_permissions in wxt.config.ts, and read its client ID from a new WXT_* variable. google-drive.ts and dropbox.ts are the examples for the implicit and PKCE flows. The Adding a service section of the cloud connections guide has the full checklist, including the privacy policy.
Using Screen Tools
- Installation and Updating
- Firefox
- Recording
- Screenshots
- Uploading
- Settings
- Troubleshooting and FAQ
- Privacy
How it works
Contributing