Skip to content

DownloadManager Architecture

Bear Huang edited this page May 9, 2026 · 3 revisions

DownloadManager β€” Full Architecture Reference

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.


1. Component Map

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, WriterFactory uses 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.

2. Why Multiple Download Paths Exist

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

3. High-Level Decision Flow

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:

  1. buildAutoDownloadPlan(size) chooses the automatic browser/size default
  2. applyRouteProfile(route=...) may override that plan for testing or forced routing

4. Branch Matrix

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.

4.1 dl_path mapping

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_path labels 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_link or sw_writer_resume once the /download request is issued.
  • sw_writer_resume may 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 labeled sw_writer_resume even 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 any route= override through applyRouteProfile().

5. ProgressServiceWorker (PSW) Internals

5.1 Intercept Guard

PSW only intercepts requests that pass all of:

event.request.method === 'GET'
url.origin === self.location.origin
url.pathname.endsWith('/download')

5.2 Internal Dispatch

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

5.3 TransformStream Pipeline

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

5.4 BroadcastChannel Events

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)

6. Resume Architecture

6.1 Why Resume Needs Its Own Path

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:

  1. Range header β€” tell the server to start from byte rangeStart
  2. Skip bytes β€” the server's chunk boundaries may not align with rangeStart, so the first skipBytes of the response must be discarded
  3. baseBytes accounting β€” progress bars must start at N, not at 0
  4. No new TransformStream wrap β€” wrapping a resumed stream would reset PSW's internal delivered counter 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.

6.2 Resume URL Parameters

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

6.3 Resume Config Normalisation

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)

6.4 416 Handling

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.

6.5 Automatic writer resume loop

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:

  1. Start reading from response.body.getReader()
  2. Track baseBytes + totalWritten
  3. If reader.read() throws, or EOF arrives before expectedTotal, create a transfer error
  4. If bytes have already been written, and retry budget remains, build a new resume config from the bytes already written
  5. Rebuild the URL with ff_auto_resume=1
  6. 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_writer or direct_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

7. E2EE Architecture

7.1 Encryption Model

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.

7.2 Decryption Placement

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.

7.3 E2EE Context Registration

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.

7.4 Firefox E2EE Block

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).

7.5 E2EE + Resume Interaction

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.


8. ZIP Preview Architecture

8.1 Condition

ZIP preview is enabled when previewUI.isPreviewableZip === true, which requires:

  • File is a .zip
  • Sidecar manifest (/{uid}/manifest) contains ZIP entry metadata

8.2 TeeWriter Mechanism

// 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.

8.3 ZIP Preview + PSW Cooperation

                 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.

8.4 Call sequence

$(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

9. Checksum Verification

9.1 What is verified β€” ciphertext, not plaintext

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 bytes

The 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.

9.2 Why hash the ciphertext

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.

9.3 Client-side ordering in PSW

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.

9.4 Where hashing happens per path

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)

9.5 Checksum disabled conditions

shouldVerifyChecksum = (
    !resumeConfig          ← hashing a partial stream produces wrong hash
    && !!uid               ← need UID to fetch expected hash from server
    && typeof FFLChecksum !== 'undefined'   ← library loaded
)

9.6 Result handling

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

10. Auth Gate System

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 }.

10.1 Gate types

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

10.2 Header forwarding

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.


11. Two Service Workers β€” Coexistence

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

12. Progress UI State Machine

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)

13. Complete Flow Diagrams

13.1 Normal SW Download (Branch A3 β€” most common)

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

13.2 ZIP Preview Download (Branch A1)

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

13.3 Resume Download (Branch A2)

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.

13.4 No-SW Fallback (Branch B1 family)

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

14. Configuration Reference

DownloadManager constructor options

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

Adaptive unlock delays

File size Delay before retry link appears
< 100 MB 5 seconds
100 MB – 1 GB 10 seconds
> 1 GB 15 seconds

Download URL parameters used by PSW and writer-resume flows

Not every parameter below comes from the same helper:

  • addSwConfigToUrl() contributes the base PSW-facing fields and explicit resume_* values
  • startNativeDownload() finalises the concrete request path and appends dl_path
  • buildAutomaticWriterResumeUrl() adds ff_auto_resume=1 and rebuilds the request as sw_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

15. File Dependency Graph

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)

Clone this wiki locally