-
Notifications
You must be signed in to change notification settings - Fork 5
DownloadManager Architecture
This document covers the complete download subsystem: how the browser decides which download path to take, how ProgressServiceWorker intercepts and instruments the fetch, how resume, E2EE decryption, ZIP preview, and checksum verification layer on top of each other, and why every combination exists.
| Component | File | Context | Role |
|---|---|---|---|
| DownloadManager | static/js/DownloadManager.js |
Main thread | Orchestrates everything: plans the download, registers the SW, selects the branch, manages UI state, handles resume |
| ProgressServiceWorker (PSW) | static/js/ProgressServiceWorker.js |
Service Worker | Intercepts /{uid}/download fetches; either wraps the response body in a TransformStream for progress / decrypt / checksum work, or returns a passthrough response for pass/resume flows |
| StreamSaver | static/js/StreamSaver.js |
Main thread + SW | Pipes a WritableStream directly to disk via its own MITM iframe + SW (sw.js) β no memory buffering for large files |
| BlobWriter |
DownloadManager.js (inline) |
Main thread | Accumulates chunks in memory; on close() creates a Blob and triggers <a>.click() β simple, no SW required |
| WriterFactory |
DownloadManager.js (inline) |
Main thread | Chooses BlobWriter (β€10 MB) or StreamSaver (>10 MB / unknown size) |
| TeeWriter |
static/js/PreviewUI.js (inline) |
Main thread | Proxy writer that duplicates every chunk to both the real writer and the ZIP extractor |
| ZipPreviewExtractor | static/js/ZipPreviewExtractor.js |
Main thread | Incremental ZIP parser; stores extracted files in IndexedDB for in-browser preview |
| PreviewUI | static/js/PreviewUI.js |
Main thread | Fetches sidecar manifest, builds gallery UI, wraps writer with TeeWriter |
| AuthGateRegistry |
DownloadManager.js (inline) |
Main thread | Runs auth gates (pickup code, pubkey, email OTP) in sequence before unlocking the download |
| E2EE / HTTPDecryptor | static/js/E2EE.js |
Both | AES-GCM stream decryption; usable in both SW context (importScripts) and main thread |
| FFLChecksum | static/js/Checksum.js |
Both | BLAKE2b rolling hash; verifies file integrity by fetching the expected hash from /{uid}/checksum
|
Notes:
- In current builds,
WriterFactoryuses BlobWriter only when the size is known and is<= 10 MB; large or unknown-size files use StreamSaver when that path is available. - BlobWriter itself does not require StreamSaver, but it may still run inside a larger download flow where PSW is present for progress or auth handling.
No single technique works for every combination of browser, file size, encryption, and resume state. The table below shows which constraint forces each path:
| Constraint | Problem | Solution forced |
|---|---|---|
| Firefox + large file (>512 MB) | Firefox drops the browser download if a SW TransformStream wraps a long-lived body |
Native <a> passthrough β browser owns the download |
| E2EE + Firefox large file | Decryption requires JS context; passthrough bypasses JS | Block download, show "use Chromium" UI |
| Resume | Server must receive a Range header; SW must not re-wrap the resumed stream with a new TransformStream that resets progress accounting |
SW passthrough path (ff_pass=1) with explicit resume_* URL params |
| ZIP preview | Preview requires the byte stream to reach both the ZIP parser and the disk writer simultaneously |
TeeWriter wrapping; fetchToWriter() called with forceWriter: true
|
| Small file (<10 MB) | StreamSaver adds a full MITM iframe + SW; unnecessary overhead |
BlobWriter β pure in-memory, no secondary SW |
| No Service Worker support | PSW unavailable or not controlling the page (for example non-HTTPS, registration failure, or missing SW support) | Direct fetchToWriter() with manual progress callbacks, or plain <a> tag |
| Auth headers | Cookies work for native <a> downloads but not for custom headers (X-FFL-Pickup, X-FFL-Proof, etc.) |
Auth headers forwarded to PSW via postMessage; PSW injects them into the upstream fetch |
startDownload()
β
ββ E2EE + Firefox + large? βββββββββββββββββββββββββββΊ BLOCK (show error UI)
β
ββ getPlannedMode()
β ββ Chromium (any size) β mode: 'sw'
β ββ Firefox β€ 512 MB β mode: 'sw'
β ββ Firefox > 512 MB / unknown β mode: 'pass'
β
ββ addSwConfigToUrl()
β adds the base PSW-facing parameters:
β dl, size, reportBytes, reportMs, debug,
β ff_pass (if pass mode), e2ee, resume_* params
β
ββ showStartingUI()
β
ββ ensureProgressSWControlled() ββββ registers PSW, waits for controller
β
ββ startNativeDownload()
β
ββ forceNativeLink? ββββββββββββββββββββββββββΊ <a> tag (cookie-only auth)
β
ββ SW available?
β ββ writer + resumeConfig βββββββββββββββββΊ fetchToWriter (ff_pass=1, SW passthrough resume)
β ββ writer + forceWriter ββββββββββββββββββΊ fetchToWriter (SW TransformStream, ZIP preview path)
β ββ no writer βββββββββββββββββββββββββββββΊ <a> tag (SW handles everything natively)
β
ββ No SW
ββ writer ββββββββββββββββββββββββββββββββΊ fetchToWriter (manual progress callbacks)
ββ no writer βββββββββββββββββββββββββββββΊ <a> tag (no progress tracking)
In current builds, getPlannedMode() is a two-step decision:
-
buildAutoDownloadPlan(size)chooses the automatic browser/size default -
applyRouteProfile(route=...)may override that plan for testing or forced routing
Every HTTP download enters one of these execution branches inside startNativeDownload().
This matrix is complete for the initial branch selection. Some writer-based branches can
perform additional internal retry / resume work after they start.
| # | SW? | Writer? | Resume? | forceWriter? | Branch | Progress source | dl_path |
|---|---|---|---|---|---|---|---|
| A1 | β | β | β | β |
fetchToWriter() β SW TransformStream stays in path |
PSW BroadcastChannel
|
sw_writer |
| A2 | β | β | β | β |
fetchToWriter() via ff_pass=1 β SW adds Range + auth, but no TransformStream wrap |
Manual progressCallback in fetchToWriter
|
sw_writer_resume |
| A3 | β | β | β | β | Native <a> tag |
PSW BroadcastChannel
|
sw_native_link |
| B1a | β | β | β | β |
fetchToWriter() direct β manual event simulation with resume config |
Manual progressCallback
|
direct_writer_resume |
| B1b | β | β | β | β |
fetchToWriter() direct β manual event simulation without resume |
Manual progressCallback
|
direct_writer |
| B2 | β | β | β | β | Native <a> tag |
None (adaptive unlock only) | direct_native_link |
| F | β | β | β | β (forceNativeLink) | Native <a> tag |
None | direct_native_link |
ZIP preview always uses branch A1. It requires SW (for progress events) + TeeWriter (for simultaneous disk-save and ZIP extraction) +
forceWriter: true(to keep the TransformStream path even though there is no resume).
Branch B1 has two runtime variants. Current implementation distinguishes one no-SW writer path with a resume config (
direct_writer_resume) and one without resume (direct_writer).
Important nuance:
- The matrix above describes the initial branch chosen by
startNativeDownload(). - It does not fully describe what can happen later inside
fetchToWriter(). - In current builds, writer-based paths can automatically convert a mid-stream failure into a follow-up resume request while staying in the same high-level branch family.
dl_path is a stable, product-facing label added to the final /download URL so different
request builders can converge on the same concrete HTTP path names without exposing internal
branch log strings.
dl_path |
Actual behavior | Typical trigger |
|---|---|---|
sw_writer_resume |
Main thread consumes the response stream and appends to an existing writer; PSW still injects auth/range and stays in passthrough mode (ff_pass=1) |
Resume / automatic writer retry |
sw_writer |
Main thread writes to a provided writer while PSW keeps the TransformStream path alive for progress broadcasts | ZIP preview / writer-first flows |
sw_native_link |
Native <a> download, but PSW still intercepts /download and emits progress / completion events |
Default HTTP path when SW is available and there is no writer |
direct_writer_resume |
Main thread calls fetchToWriter() directly with a resume config; no PSW controller participates |
Resume fallback when SW is unavailable |
direct_writer |
Main thread calls fetchToWriter() directly without resume |
Writer flow when SW is unavailable |
direct_native_link |
Browser-owned native <a> download with no writer and no PSW instrumentation |
forceNativeLink or final no-SW/no-writer fallback |
Important nuance:
-
dl_pathlabels the actual HTTP download path, not the higher-level product mode (P2P,P2P+E2EE, relay, fallback, etc.). - A WebRTC-first transfer that later falls back to HTTP may still produce
sw_native_linkorsw_writer_resumeonce the/downloadrequest is issued. -
sw_writer_resumemay appear both for explicit resume and for automatic writer retry after a premature EOF, because both paths converge on the same HTTP behavior. - Current automatic writer retry always rebuilds the follow-up request with
buildAutomaticWriterResumeUrl(), so the resulting request is labeledsw_writer_resumeeven if the original writer flow started in a no-SW branch family. -
getPlannedMode()is not only browser/size detection in current builds. It first creates the automatic plan from browser + file size, then applies anyroute=override throughapplyRouteProfile().
PSW only intercepts requests that pass all of:
event.request.method === 'GET'
url.origin === self.location.origin
url.pathname.endsWith('/download')
fetch event
β
ββ No dl param AND no resume_start βββββββββββββββββββΊ Full passthrough (SW does nothing)
β
ββ Range header present, no resume_* ββββββββββββββββββΊ Passthrough (browser-level range, not ours)
β
ββ ff_pass=1 + resume_start βββββββββββββββββββββββββββΊ handlePassthroughForResume()
β β’ Builds request with Range + auth headers
β β’ Fetches upstream
β β’ Broadcasts download-started(total, baseBytes)
β β’ Returns raw upstream response (no wrapping)
β
ββ Normal interception ββββββββββββββββββββββββββββββββΊ handleDownloadWithTransform()
β’ Builds request with Range + auth headers (if resume)
β’ Wraps body in TransformStream
β’ Broadcasts progress via BroadcastChannel
upstream.body
β
βΌ
TransformStream.transform(chunk)
β
ββ checksumVerifier.update(chunk) β BLAKE2b rolling hash (pre-decrypt)
β
ββ httpDecryptor.decryptChunk(chunk) β AES-GCM (if e2ee=1)
β
ββ skip leading bytes β resume_skip: discard overlap with already-written data
β
ββ controller.enqueue(processedChunk) β forward to browser / fetchToWriter reader
β
ββ broadcast progress β every REPORT_EVERY_BYTES (5 MB) or REPORT_EVERY_MS (250 ms)
β
TransformStream.flush()
ββ httpDecryptor.flush() β drain AES-GCM tail block
ββ broadcast download-complete
ββ checksumVerifier.finalizeAndVerify() β fetch expected hash, compare
ββ broadcast download-checksum
All events are posted on channel 'dl-progress' and keyed by downloadId to prevent
cross-tab interference.
| Event type | Payload fields | When emitted |
|---|---|---|
download-started |
id, total, sent |
After upstream fetch succeeds, before first chunk |
download-progress |
id, sent, total |
Every 5 MB or 250 ms (configurable) |
download-complete |
id, sent, total, serverId |
TransformStream flush, all bytes delivered |
download-error |
id, message |
Any unhandled error |
download-checksum |
id, transport, verified, algorithm, localChecksum, β¦ |
After BLAKE2b finalize |
debug |
message |
Firefox only (console.log not visible in FF SW) |
A resumed download has already written N bytes to disk. The browser cannot append to
an existing partial file through a normal fetch, so resume requires:
-
Range header β tell the server to start from byte
rangeStart -
Skip bytes β the server's chunk boundaries may not align with
rangeStart, so the firstskipBytesof the response must be discarded -
baseBytes accounting β progress bars must start at
N, not at 0 -
No new TransformStream wrap β wrapping a resumed stream would reset PSW's internal
deliveredcounter to 0, breaking the progress display
Path A2 (ff_pass=1 + resume) satisfies all four: PSW injects the Range header and auth
headers, then returns the raw upstream response without a TransformStream wrapper. The main
thread's fetchToWriter() reads the stream and handles skip + progress manually.
addSwConfigToUrl() serialises the explicit resume config into URL parameters so PSW can
reconstruct it when it intercepts the fetch (SW cannot read the JS call stack).
Automatic writer retry reuses the same resume_* parameter family, but it builds the follow-up
URL through buildAutomaticWriterResumeUrl() and additionally sets ff_auto_resume=1.
| URL param | Source field | Meaning |
|---|---|---|
resume_start |
rangeStart |
First byte to request from server (Range: bytes=N-) |
resume_base |
baseBytes |
Bytes already on disk; progress display starts here |
resume_skip |
skipBytes |
Bytes to discard from start of response (alignment gap) |
resume_expected |
expectedSize |
Full file size; used to compute percentage when Content-Length is absent |
normalizeResumeOptions() enforces invariants before any network activity:
rangeStart = min(rangeStart, baseBytes) β never go further back than baseBytes
skipBytes = baseBytes - rangeStart β discard exactly the overlap gap
(or caller-provided skip if smaller)
expectedSize = 0 if unknown
if rangeStart >= expectedSize β abort (file already complete)
If the server returns 416 Range Not Satisfiable (file is smaller than rangeStart, e.g.
the server file was replaced), both PSW and fetchToWriter fall back to a full-file fetch
without a Range header.
Current DownloadManager.js adds a second resume layer on top of the explicit resume design
above.
When fetchToWriter() is already consuming a stream into an existing writer and a mid-stream
failure occurs, it may automatically retry from the number of bytes already written instead of
surfacing the error immediately.
This applies to the writer-based HTTP paths:
- A2 /
sw_writer_resume - B1b /
direct_writer - B1a /
direct_writer_resume
The retry loop works like this:
- Start reading from
response.body.getReader() - Track
baseBytes + totalWritten - If
reader.read()throws, or EOF arrives beforeexpectedTotal, create a transfer error - If bytes have already been written, and retry budget remains, build a new resume config from the bytes already written
- Rebuild the URL with
ff_auto_resume=1 - Re-enter the outer
while (true)loop and continue writing into the same writer
Configuration:
DEFAULT_MAX_AUTOMATIC_WRITER_RESUME_ATTEMPTS = 8- Runtime override:
options.maxAutomaticWriterResumeAttempts
Resulting request-shape impact:
- Automatic writer retry stays in the same high-level writer path
- The rebuilt URL carries
ff_auto_resume=1 - The rebuilt request is labeled
sw_writer_resume - This is true even when the original no-SW writer path started as
direct_writerordirect_writer_resume, because the retry helper unconditionally switches to the shared passthrough-resume request shape
Why this matters:
- The branch matrix alone can make writer flows look single-shot
- In reality, current writer flows can survive several premature EOF / stream-read failures before finally surfacing an error
- This is one reason a later build can succeed in weak networks where an older build failed even though the top-level branch name still looks the same
Files are encrypted with AES-GCM before upload. Each HTTP chunk contains one or more AES-GCM frames. The encryption context (key, IV, frame size) is delivered out-of-band (page template or sidecar manifest) and never appears in the download URL.
E2EE decryption can happen in two places:
| Location | When used | How triggered |
|---|---|---|
| PSW TransformStream | Normal download (branch A1, A3) |
e2ee=1 URL param + e2ee-context postMessage |
fetchToWriter() main thread |
Resume download (branch A2) |
this.e2eeEnabled === true + this.httpDecryptor instance |
Why the split? In the resume path (A2) PSW returns the raw upstream response without
wrapping it in a TransformStream. The main thread's fetchToWriter() must therefore apply
decryption itself. Both paths use the same HTTPDecryptor class from E2EE.js.
Main thread PSW
ββββββββββ βββ
onServiceWorkerReadyCallback()
β
ββ controller.postMessage({
type: 'e2ee-context',
downloadId: activeDlId,
context: { key, iv, frameSize, β¦ }
})
β
βΌ
e2eeContexts.set(downloadId, context)
β
handleDownloadWithTransform()
β
HTTPDecryptor.fromContext(context)
PSW also accepts a '__pre_registered__' key for cases where the download ID is not yet
known when the context is sent.
E2EE + Firefox + mode: 'pass' is an unsupported combination:
E2EE enabled? AND Firefox? AND plan.mode === 'pass'?
β
βββΊ showE2EEFirefoxBlockedUI()
Sets progress bar to red
Explains the constraint
Suggests Chromium or the CLI tool
returns (download never starts)
StreamSaver's MITM iframe runs in a separate SW context and cannot decrypt. Pass-through mode gives the encrypted bytes directly to the browser's download manager, which cannot decrypt either. The only supported paths for E2EE on Firefox are files β€512 MB (uses TransformStream in PSW).
Resume and E2EE interact because AES-GCM is not byte-addressable β you cannot start
decrypting from an arbitrary byte offset without knowing the frame state. HTTPDecryptor
exposes setResumeState(rangeStart) which fast-forwards the frame counter so decryption
can resume correctly from a frame boundary.
ZIP preview is enabled when previewUI.isPreviewableZip === true, which requires:
- File is a
.zip - Sidecar manifest (
/{uid}/manifest) contains ZIP entry metadata
// PreviewUI.wrapWriter(realWriter) returns:
{
write(chunk) {
extractor.feed(chunk); // ZIP parser gets every chunk
return realWriter.write(chunk); // disk writer also gets every chunk
},
close() { return realWriter.close(); },
abort(e) { return realWriter.abort(e); }
}No byte is duplicated in memory β extractor.feed() is called before realWriter.write().
The chunk reference is the same object; ZipPreviewExtractor must not mutate it.
Main Thread PSW
βββββββββββ βββ
fetchToWriter(url, teeWriter, β¦)
β
fetch(url) βββββββββββββββββββββββββββββββΊ intercepts
TransformStream wraps body
counts bytes
broadcasts events
βββββββββββββββββββββββββββββ transformed response
β
reader.read() β chunk
β
teeWriter.write(chunk)
ββββΊ ZipPreviewExtractor.feed(chunk)
β βββΊ IndexedDB β Gallery UI
ββββΊ StreamSaver / BlobWriter
PSW provides real-time progress events to drive the progress bar.
fetchToWriter drives the stream consumption and writes to disk.
ZipPreviewExtractor sees the same bytes in parallel.
$(document).ready
β
ββ new PreviewUI()
β ββ fetch /{uid}/manifest β sidecar metadata
β
ββ await previewUI.ready()
β
ββ if previewUI.isPreviewableZip:
β writerContext = WriterFactory.create(fileName, fileSize)
β teeWriter = await previewUI.wrapWriter(writerContext.writer)
β downloadManager.startDownload({ writer: teeWriter, forceWriter: true })
β
ββ else:
downloadManager.startDownload() β Path B / native
The server computes the BLAKE2b-256 hash after encryption (i.e. over the ciphertext
bytes that travel on the wire). This is the authoritative definition, confirmed in
Server.py (bases/Server.py):
if encryptor:
data = encryptor.encryptChunk(data) # encrypt first
checksumSession.update(data) # hash the already-encrypted bytesThe ChecksumRecord stored server-side carries an e2ee: bool flag so clients can
tell whether the recorded hash covers ciphertext or plaintext:
@dataclass(frozen=True)
class ChecksumRecord:
algorithm: str
checksum: str
transport: str
e2ee: bool # True β hash is over ciphertext
# False β hash is over plaintext (no E2EE)
...This field is exposed through the GET /{uid}/checksum endpoint response so the
JavaScript client can reason about what to hash.
| Alternative | Problem |
|---|---|
| Hash plaintext on server, hash plaintext on client (after decrypt) | Requires client to buffer entire file before verifying β defeats streaming |
| Hash plaintext on server, hash ciphertext on client | Hashes would never match |
| Hash ciphertext on server, hash ciphertext on client (before decrypt) | Verifies wire integrity immediately as bytes arrive, before any decryption work; streaming-safe |
Hashing before decryption also protects against malicious ciphertext being fed to the AES-GCM decryptor β integrity is verified at the transport layer first.
PSW's TransformStream processes each chunk in this exact order:
chunk (raw from upstream / ciphertext)
β
ββ checksumVerifier.update(chunk) β hash ciphertext β (matches server)
β
ββ httpDecryptor.decryptChunk(chunk) β decrypt after hash
β
ββ controller.enqueue(processedChunk) β plaintext delivered to browser
This ordering is intentional and correct β the hash is computed over the same bytes the server hashed.
| Path | Hashing location | Hashes what |
|---|---|---|
| Normal SW path (A1, A3) | PSW TransformStream | Ciphertext (raw upstream chunk, before decryptChunk) |
| Resume path (A2) | fetchToWriter() main thread |
Ciphertext (raw reader.read() value, before writer) |
| No-SW path (B1) | fetchToWriter() main thread |
Ciphertext (same as A2) |
shouldVerifyChecksum = (
!resumeConfig β hashing a partial stream produces wrong hash
&& !!uid β need UID to fetch expected hash from server
&& typeof FFLChecksum !== 'undefined' β library loaded
)
PSW flush() β checksumVerifier.finalizeAndVerify()
β
ββ broadcast({ type: 'download-checksum', verified: true/false, β¦ })
DownloadManager.setupBroadcastChannel()
β
ββ handleChecksumVerificationResult(result)
ββ verified: true β showChecksumVerifiedMessage() (badge appended to heading)
ββ pending / transportMismatch β log only (no UI change)
ββ verified: false β clearChecksumVerifiedMessage()
+ update status with "checksum failed" warning
Auth gates sit in front of the download β they must all pass before startDownload() is
called. All gates share a common interface: { validate(), apply(), focus(), authHeaders }.
| Gate | Header injected | Mechanism |
|---|---|---|
PickupCodeGate |
X-FFL-Pickup |
6-digit code validated against server |
PubkeyGate |
X-FFL-Proof |
RSA-OAEP decrypt server challenge with .fflkey private key |
EmailGate |
X-FFL-EmailOTP, X-FFL-EmailAddress, X-FFL-EmailLink
|
Email OTP; supports allowlist enforcement |
Once unlocked, the collected authHeaders are stored on the DownloadManager instance and
forwarded to PSW via postMessage keyed by downloadId:
DownloadManager.startNativeDownload()
β
ββ navigator.serviceWorker.controller.postMessage({
type: 'auth-headers',
downloadId: activeDlId,
headers: this.authHeaders β {X-FFL-Pickup: '123456'}
})
PSW stores them in authHeadersMap and injects them into every upstream fetch via
buildResumeAwareRequest(). This is necessary because native <a> downloads cannot carry
custom request headers β PSW intercepts the fetch and adds them.
| SW | File | Registered by | Scope | Purpose |
|---|---|---|---|---|
| ProgressServiceWorker | ProgressServiceWorker.js |
DownloadManager.ensureProgressSWControlled() |
/ |
Intercepts /{uid}/download, instruments fetch |
| StreamSaver SW |
sw.js (StreamSaver internal) |
WriterFactory.create() via MITM iframe |
/static/assets/ |
Creates virtual download file; pipes WritableStream to disk |
They never conflict because:
- PSW intercepts only paths ending in
/download - StreamSaver's SW intercepts only synthetic blob URLs under its own scope
- They operate on different ends: PSW on the incoming HTTP fetch, StreamSaver on the outgoing file-save
Initial
β
ββ showStartingUI()
β deterministic: "Preparing download..." (known size)
β indeterminate: "Preparing X file..." (unknown size or pass mode)
β
ββ onDownloadStart(total, initialSent)
β pass mode β showFirefoxDownloadProgress() (animated indeterminate)
β sw mode β updateProgressBar(initialSent/total * 100)
β
ββ handleDownloadProgress(sent, total)
β known total β deterministic %
β unknown β showIndeterminateProgress() + bytes transferred
β
ββ scheduleAdaptiveUnlock() β fires after delay, shows retry link
β <100 MB β 5 s
β 100 MBβ1 GB β 10 s
β >1 GB β 15 s
β
ββ showRetryLink()
β pass mode or progress detected β subtle text link
β no progress β prominent amber button
β ββ Firefox pass mode β startFirefoxStallMonitoring() (timeout β upgrade button style)
β ββ SW mode β startProgressMonitoring() (interval stall detector)
β
ββ handleDownloadComplete(total)
updateProgressBar(100)
updateStatus("Download completed!")
showChecksumVerifiedMessage() (if verified)
POST /{uid}/complete β ACK server so relay can shut down
receiptConfirmationUI.show() (if server requests it)
Page DownloadManager PSW Server
ββββ βββββββββββββββ βββ ββββββ
$(ready)
ββ startDownload()
β
ββ ensureProgressSWControlled()
register ProgressServiceWorker.js
β
βΌ
βββ SW ready βββββββββββββββββββββββββββββββββ
β
ββ addSwConfigToUrl() adds base PSW-facing params such as
dl=UUID, size, reportBytes, reportMs
β
ββ startNativeDownload()
ββ appends `dl_path`
ββ triggerNativeDownloadLink(url)
β
βΌ
<a href="/{uid}/download?dl=X&size=N&..."> click
β
βΌ (fetch intercepted)
intercepts fetch
fetch(upstream URL) βββββββββββββββΊ GET /{uid}/download
βββ 200 + body ββββββ
wrap body in TransformStream
broadcast download-started
βββ chunk ββ
count bytes
broadcast download-progress
enqueue chunk to browser
...
broadcast download-complete
βββ broadcast events ββββββββββββββββββ
handleDownloadStarted / Progress / Complete
updateProgressBar / updateStatus
Page DownloadManager + PreviewUI PSW ZipPreviewExtractor
ββββ ββββββββββββββββββββββββββ βββ βββββββββββββββββββ
fetch /{uid}/manifest
βββ ZIP metadata ββ
previewUI.isPreviewableZip = true
writerContext = WriterFactory.create(name, size)
teeWriter = previewUI.wrapWriter(writerContext.writer)
startDownload({ writer: teeWriter, forceWriter: true })
β
ββ ensureProgressSWControlled()
ββ startNativeDownload(url, filename, { writer: teeWriter, forceWriter: true })
ββ BRANCH A1: fetchToWriter(url, teeWriter, β¦)
β
fetch(url) βββββββββββββββββββββββββββΊ intercepts
TransformStream
broadcast events
βββ transformed response βββββββββββββ
β
reader.read() β chunk
β
teeWriter.write(chunk)
ββββββββββββββββββββββββββββββββββββββββΊ extractor.feed(chunk)
β β
β parse ZIP headers
β extract file entry
β IndexedDB.put(file)
β gallery badge: "Ready"
β
ββββΊ StreamSaver.write(chunk) β disk
β
βββ broadcast events ββββββββββ
progress bar updates in real-time
Page DownloadManager PSW Server
ββββ βββββββββββββββ βββ ββββββ
(partial file on disk, e.g. 20 MB of 100 MB downloaded)
setResumeConfig({ baseBytes: 20MB, rangeStart: 20MB, skipBytes: 0, expectedSize: 100MB })
startDownload({ writer: streamSaverWriter, resume: { β¦ } })
β
ββ addSwConfigToUrl()
adds the base resume parameters:
ff_pass=1, resume_start=20MB, resume_base=20MB,
resume_expected=100MB, dl=UUID
β
ββ startNativeDownload()
ββ appends: dl_path=sw_writer_resume
ββ BRANCH A2: fetchToWriter(url+ff_pass=1, writer, false, resumeConfig, progressCallback)
β
fetch(url) ββββββββββββββββββββββββββββββΊ intercepts (ff_pass=1 + resume)
handlePassthroughForResume()
build request with:
Range: bytes=20971520-
X-FFL-Pickup: 123456 (if set)
fetch upstream βββββββββββββββββΊ GET /download
Range: bytes=20MB-
βββ 206 + body ββ
broadcast download-started(total=100MB, sent=20MB)
return raw upstream response (no TransformStream)
βββ raw 206 response ββββββββββββββββββββ
β
read stream chunk by chunk
progressCallback(20MB + written, 100MB)
writer.write(chunk) β appended to partial file on disk
...
writer.close()
handleDownloadComplete(100MB)
If this no-SW writer flow later needs automatic retry, the follow-up request still reuses the
shared passthrough-resume URL builder. In current builds that means the first request may start
as direct_writer / direct_writer_resume, but a later automatic retry request is rebuilt as
sw_writer_resume with ff_auto_resume=1.
Page DownloadManager Server
ββββ βββββββββββββββ ββββββ
ensureProgressSWControlled() β false (registration failed or non-HTTPS)
β
ββ startNativeDownload()
ββ BRANCH B1a/B1b: no SW + writer
β
handleDownloadStarted(id, expectedSize, baseBytes) β simulated
β
fetchToWriter(url, writer, e2eeEnabled, resumeConfig, progressCallback)
β
fetch(url, { headers: Range + authHeaders }) βββββββββββββββββββββββΊ GET /download
βββ 200/206 + body βββ
reader.read() β chunk
E2EE decrypt (if enabled, via this.httpDecryptor)
progressCallback(sent, total) β updates UI
writer.write(chunk)
...
writer.close()
β
handleDownloadComplete(totalSize) β simulated
| Option | Default | Effect |
|---|---|---|
ffSwLimit |
512 MB | Firefox: files larger than this use pass-through mode |
stallTimeoutMs |
60 000 ms | Pass-through mode: timeout before retry button becomes prominent |
stallCheckInterval |
5 000 ms | SW mode: interval between stall checks |
stallCheckIntervalBackground |
30 000 ms | SW mode stall check interval when tab is hidden |
stallThreshold |
3 checks | SW mode: consecutive no-progress checks before retry button is upgraded |
swReportEveryBytes |
5 MB | Forwarded to PSW as reportBytes URL param |
swReportEveryMs |
250 ms | Forwarded to PSW as reportMs URL param |
adaptiveDelayConfig |
see below | Controls retry unlock timing per file size tier |
e2eeEnabled |
false | Activates E2EE decryption path |
authHeaders |
null | Headers forwarded to PSW and used in direct fetches |
maxAutomaticWriterResumeAttempts |
8 |
Max automatic writer retries inside writer-based flows before surfacing failure |
| File size | Delay before retry link appears |
|---|---|
| < 100 MB | 5 seconds |
| 100 MB β 1 GB | 10 seconds |
| > 1 GB | 15 seconds |
Not every parameter below comes from the same helper:
-
addSwConfigToUrl()contributes the base PSW-facing fields and explicitresume_*values -
startNativeDownload()finalises the concrete request path and appendsdl_path -
buildAutomaticWriterResumeUrl()addsff_auto_resume=1and rebuilds the request assw_writer_resume
| Parameter | Type | Description |
|---|---|---|
dl |
UUID | Download ID for BroadcastChannel filtering and auth header lookup |
size |
number | File size hint (fallback when Content-Length header is absent) |
reportBytes |
number | Min bytes between progress broadcasts |
reportMs |
number | Min ms between progress broadcasts |
debug |
1 |
Enable verbose PSW logging (also forwarded to Firefox via debug broadcast) |
ff_pass |
1 |
Skip TransformStream; PSW adds auth/range but returns raw response |
ff_auto_resume |
1 |
This request was generated by automatic writer retry rather than the initial HTTP request |
e2ee |
1 |
Activate E2EE decryption inside PSW TransformStream |
dl_path |
string | Stable request-path label describing the actual HTTP path (for example sw_writer_resume) |
resume_start |
number | Range byte offset for upstream request |
resume_base |
number | Bytes already downloaded; progress display baseline |
resume_skip |
number | Bytes to discard from the start of the response stream |
resume_expected |
number | Expected full file size for percentage calculation |
FileDownloading.html
β
ββ StreamSaver.js (no dependencies)
β ββ /static/assets/mitm.html + sw.js (StreamSaver's own SW)
β
ββ E2EE.js (loaded in both main thread and PSW via importScripts)
β
ββ Checksum.js (BLAKE2b wrapper, loaded in both contexts)
β ββ hash-wasm blake2b (CDN, loaded via importScripts in PSW)
β
ββ DownloadManager.js (main thread only)
β ββ class AuthGateRegistry
β ββ class PickupCodeGate
β ββ class PubkeyGate
β ββ class EmailGate
β ββ class ReceiptConfirmationUI
β ββ class DownloadManager
β ββ class BlobWriter
β ββ class WriterFactory
β
ββ ProgressServiceWorker.js (SW context β registered dynamically by DownloadManager)
β ββ importScripts E2EE.js
β ββ importScripts hash-wasm CDN
β ββ importScripts Checksum.js
β
ββ ZipPreviewExtractor.js (main thread)
β ββ pako.min.js (DEFLATE decompression)
β
ββ PreviewUI.js (main thread)
ββ TeeWriter (inline class)