Skip to content

Upload Pipeline

Tom Bouquet edited this page Nov 2, 2026 · 1 revision

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.

Stages

An upload passes through these stages:

  1. 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.
  2. 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.
  3. Upload. startUpload writes a row to the uploads store and hands the file to the connector, which talks to the service through a fetch that adds and renews the token.
  4. 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.

Connectors

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.

Signing in

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 with interactive: false, prompt=none and the account's email as login_hint. Google answers that with a JavaScript redirect, so in Chrome the call also passes abortOnLoadForNonInteractive: false and 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 postToken exchanges at tokenUrl for access and refresh tokens. token_access_type=offline asks 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.

Optional permissions

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.

Tokens

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_in counts 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.

Running an upload

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:

  1. Builds an UploadContext for the connector: the AuthorizedFetch, an AbortSignal for Stop, onProgress for bytes the service has confirmed, and saveResumeState.
  2. Takes a Web Lock named screen-tools-upload-<id> for as long as the upload runs, so other windows can tell it's alive.
  3. Calls the connector's upload. An UploadError with retryable: true is tried again up to 3 times, after 1, 2 and 4 seconds. Any other error fails the upload.
  4. On success, sets the row to done with 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.

Errors

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.

Interrupted uploads

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:

  • resume reloads the file and passes the connector the resumeState it 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.
  • retry is resume with 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.

Upload state

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.

Google Drive

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:

  1. A POST opens a session with the file's name, type, size and folder. Drive returns the session URL in Location, and the connector saves it as its resume state, with the file name and size.
  2. PUT requests send the file in 8 MiB chunks, a multiple of the 256 KiB Drive requires. A 308 answer's Range header says how far Drive has got. Three 308s in a row with no progress fail the attempt as stalled.
  3. A 200 or 201 returns the file's id and webViewLink.

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.

Dropbox

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/upload request, unless it's resuming.
  • Larger files use an upload session: upload_session/start with the first 8 MiB chunk, append_v2 for each next chunk, then finish with 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.

Upload servers

An upload server is any server that hands out an upload URL and a public link. upload-server.ts has both sides:

  • Adding one. addUploadServer checks the name and URL, requests the host permission, sends a HEAD to the endpoint to check it answers, and stores a Connection with the id server:<random UUID>. The server's name, endpoint and optional access token go in its server field. It has no sign-in, so its token set is a placeholder that never expires.
  • Uploading. uploadServerConnector builds a connector from those settings, with links: 'always', so the dialogs show no share switch. Each upload is two requests: a POST to the endpoint with the file name, type and size, plus Authorization: Bearer <token> when one is set, which answers with uploadUrl, publicUrl and optional headers; then a PUT of the whole file to uploadUrl. The PUT uses XMLHttpRequest, 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.

User interface

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.

Adding a service

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.

Clone this wiki locally