Skip to content

Commit 58afd21

Browse files
feat: stream installer download to keep modal in sync with transfer (#408)
1 parent 1243e28 commit 58afd21

7 files changed

Lines changed: 455 additions & 39 deletions

File tree

src/modules/file.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,98 @@
1-
const triggerFileDownload = (link: string): void => {
1+
import type { DownloadProgressCallback } from './file.types'
2+
3+
const clickAnchor = (href: string, downloadAttr: string | null): void => {
24
const a = document.createElement('a')
3-
a.href = link
4-
a.setAttribute('download', '')
5+
a.href = href
6+
if (downloadAttr !== null) {
7+
a.setAttribute('download', downloadAttr)
8+
}
59
document.body.appendChild(a)
610
a.click()
7-
document.body.removeChild(a)
11+
// Defer removal so the browser has dispatched the navigation/download.
12+
// Synchronously removing the anchor right after `click()` aborts the
13+
// download in some Chromium versions.
14+
requestAnimationFrame(() => a.remove())
15+
}
16+
17+
const triggerFileDownload = (link: string, filename = ''): void => {
18+
// Chromium ignores the `download` attribute on cross-origin URLs unless
19+
// the response sets Content-Disposition. Setting it on a cross-origin URL
20+
// without that header has historically dropped the download silently for
21+
// some `.dmg` responses. Only attach the attribute when we're same-origin.
22+
let downloadAttr: string | null = null
23+
try {
24+
const url = new URL(link, window.location.href)
25+
if (url.origin === window.location.origin) {
26+
downloadAttr = filename || ''
27+
}
28+
} catch {
29+
// Invalid URL: fall through and let the browser handle it.
30+
}
31+
clickAnchor(link, downloadAttr)
32+
}
33+
34+
const triggerBlobDownload = (blob: Blob, filename: string): void => {
35+
const blobUrl = URL.createObjectURL(blob)
36+
clickAnchor(blobUrl, filename)
37+
// The browser takes its own internal reference to the blob once the click
38+
// dispatches the download, so revoking on the next frame frees memory
39+
// without aborting the save.
40+
requestAnimationFrame(() => URL.revokeObjectURL(blobUrl))
41+
}
42+
43+
/**
44+
* Streams a download via fetch + ReadableStream so the caller has a real
45+
* progress signal (loaded/total per chunk) instead of the synchronous
46+
* `<a>.click()`, which returns immediately while the browser keeps fetching
47+
* in the background.
48+
*
49+
* The assembled bytes go to disk via a `blob:` URL. This intentionally
50+
* bypasses the browser's native download manager — useful when we want to
51+
* keep a "Downloading..." UI in sync with the actual transfer, but it
52+
* changes how the OS records the file's origin (see callers for the
53+
* platform-specific implications: Windows-NSIS bakes attribution into the
54+
* binary so this is safe; macOS-DMG depends on the kMDItemWhereFroms xattr
55+
* which a blob URL would corrupt).
56+
*
57+
* Throws on fetch error / non-2xx so the caller can fall back to the
58+
* native anchor path.
59+
*/
60+
async function downloadFileWithProgress(
61+
url: string,
62+
filename: string,
63+
onProgress?: DownloadProgressCallback,
64+
signal?: AbortSignal
65+
): Promise<void> {
66+
const response = await fetch(url, {
67+
method: 'GET',
68+
mode: 'cors',
69+
credentials: 'omit',
70+
cache: 'no-store',
71+
redirect: 'follow',
72+
signal
73+
})
74+
75+
if (!response.ok) {
76+
throw new Error(`Download failed with status ${response.status}`)
77+
}
78+
if (!response.body) {
79+
throw new Error('Response body is null')
80+
}
81+
82+
const contentLengthHeader = response.headers.get('content-length')
83+
const total = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0
84+
let loaded = 0
85+
86+
const progressStream = new TransformStream<Uint8Array, Uint8Array>({
87+
transform(chunk, controller) {
88+
loaded += chunk.byteLength
89+
onProgress?.({ loaded, total })
90+
controller.enqueue(chunk)
91+
}
92+
})
93+
94+
const blob = await new Response(response.body.pipeThrough(progressStream)).blob()
95+
triggerBlobDownload(blob, filename)
896
}
997

10-
export { triggerFileDownload }
98+
export { downloadFileWithProgress, triggerFileDownload }

src/modules/file.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
type DownloadProgressCallback = (progress: { loaded: number; total: number }) => void
2+
3+
export type { DownloadProgressCallback }
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { OperativeSystem } from '../types/download.types'
2+
import { FALLBACK_LOADER_HOLD_MS, streamOrFallback } from './streamOrFallback'
3+
4+
const mockTriggerFileDownload = jest.fn()
5+
const mockDownloadFileWithProgress = jest.fn()
6+
7+
jest.mock('./file', () => ({
8+
triggerFileDownload: (...args: unknown[]) => mockTriggerFileDownload(...args),
9+
downloadFileWithProgress: (...args: unknown[]) => mockDownloadFileWithProgress(...args)
10+
}))
11+
12+
describe('streamOrFallback', () => {
13+
let abortController: AbortController
14+
let onProgress: jest.Mock
15+
16+
beforeEach(() => {
17+
jest.useFakeTimers()
18+
abortController = new AbortController()
19+
onProgress = jest.fn()
20+
})
21+
22+
afterEach(() => {
23+
jest.runOnlyPendingTimers()
24+
jest.useRealTimers()
25+
jest.resetAllMocks()
26+
})
27+
28+
describe('when the OS is macOS', () => {
29+
it('should use the native anchor to preserve the kMDItemWhereFroms xattr', async () => {
30+
const promise = streamOrFallback({
31+
url: 'https://example.com/file.dmg',
32+
filename: 'Decentraland-Installer.dmg',
33+
os: OperativeSystem.MACOS,
34+
signal: abortController.signal,
35+
onProgress
36+
})
37+
38+
jest.advanceTimersByTime(FALLBACK_LOADER_HOLD_MS)
39+
await promise
40+
41+
expect(mockTriggerFileDownload).toHaveBeenCalledWith('https://example.com/file.dmg', 'Decentraland-Installer.dmg')
42+
expect(mockDownloadFileWithProgress).not.toHaveBeenCalled()
43+
})
44+
45+
it('should hold the backdrop for FALLBACK_LOADER_HOLD_MS before resolving', async () => {
46+
let resolved = false
47+
streamOrFallback({
48+
url: 'https://example.com/file.dmg',
49+
filename: 'Decentraland-Installer.dmg',
50+
os: OperativeSystem.MACOS,
51+
signal: abortController.signal,
52+
onProgress
53+
}).then(() => {
54+
resolved = true
55+
})
56+
57+
// Drain the microtask that schedules the timer, then advance just
58+
// below the threshold — the hold should still be pending.
59+
await jest.advanceTimersByTimeAsync(FALLBACK_LOADER_HOLD_MS - 1)
60+
expect(resolved).toBe(false)
61+
62+
await jest.advanceTimersByTimeAsync(1)
63+
expect(resolved).toBe(true)
64+
})
65+
})
66+
67+
describe('when the OS is Windows', () => {
68+
describe('and the streamed fetch succeeds', () => {
69+
beforeEach(() => {
70+
mockDownloadFileWithProgress.mockResolvedValue(undefined)
71+
})
72+
73+
it('should use downloadFileWithProgress and skip the native anchor', async () => {
74+
await streamOrFallback({
75+
url: 'https://example.com/file.exe',
76+
filename: 'Decentraland-Installer.exe',
77+
os: OperativeSystem.WINDOWS,
78+
signal: abortController.signal,
79+
onProgress
80+
})
81+
82+
expect(mockDownloadFileWithProgress).toHaveBeenCalledTimes(1)
83+
expect(mockTriggerFileDownload).not.toHaveBeenCalled()
84+
})
85+
86+
it('should report progress capped at 99 while the transfer is in flight', async () => {
87+
mockDownloadFileWithProgress.mockImplementation(
88+
async (_url: string, _filename: string, progressCb: (p: { loaded: number; total: number }) => void) => {
89+
progressCb({ loaded: 50, total: 100 })
90+
progressCb({ loaded: 100, total: 100 })
91+
}
92+
)
93+
94+
await streamOrFallback({
95+
url: 'https://example.com/file.exe',
96+
filename: 'Decentraland-Installer.exe',
97+
os: OperativeSystem.WINDOWS,
98+
signal: abortController.signal,
99+
onProgress
100+
})
101+
102+
expect(onProgress).toHaveBeenNthCalledWith(1, 50)
103+
expect(onProgress).toHaveBeenNthCalledWith(2, 99)
104+
})
105+
106+
it('should suppress progress updates after the signal aborts', async () => {
107+
mockDownloadFileWithProgress.mockImplementation(
108+
async (_url: string, _filename: string, progressCb: (p: { loaded: number; total: number }) => void) => {
109+
progressCb({ loaded: 25, total: 100 })
110+
abortController.abort()
111+
progressCb({ loaded: 75, total: 100 })
112+
}
113+
)
114+
115+
await streamOrFallback({
116+
url: 'https://example.com/file.exe',
117+
filename: 'Decentraland-Installer.exe',
118+
os: OperativeSystem.WINDOWS,
119+
signal: abortController.signal,
120+
onProgress
121+
})
122+
123+
expect(onProgress).toHaveBeenCalledTimes(1)
124+
expect(onProgress).toHaveBeenCalledWith(25)
125+
})
126+
127+
it('should suppress progress updates when the response has no Content-Length', async () => {
128+
mockDownloadFileWithProgress.mockImplementation(
129+
async (_url: string, _filename: string, progressCb: (p: { loaded: number; total: number }) => void) => {
130+
progressCb({ loaded: 1024, total: 0 })
131+
}
132+
)
133+
134+
await streamOrFallback({
135+
url: 'https://example.com/file.exe',
136+
filename: 'Decentraland-Installer.exe',
137+
os: OperativeSystem.WINDOWS,
138+
signal: abortController.signal,
139+
onProgress
140+
})
141+
142+
expect(onProgress).not.toHaveBeenCalled()
143+
})
144+
})
145+
146+
describe('and the streamed fetch fails', () => {
147+
beforeEach(() => {
148+
mockDownloadFileWithProgress.mockRejectedValue(new Error('CORS blocked'))
149+
jest.spyOn(console, 'warn').mockImplementation(() => {})
150+
})
151+
152+
it('should fall back to the native anchor and hold the backdrop for FALLBACK_LOADER_HOLD_MS', async () => {
153+
const promise = streamOrFallback({
154+
url: 'https://example.com/file.exe',
155+
filename: 'Decentraland-Installer.exe',
156+
os: OperativeSystem.WINDOWS,
157+
signal: abortController.signal,
158+
onProgress
159+
})
160+
161+
// Drain the rejected fetch promise, then advance through the hold.
162+
await Promise.resolve()
163+
await Promise.resolve()
164+
jest.advanceTimersByTime(FALLBACK_LOADER_HOLD_MS)
165+
await promise
166+
167+
expect(mockTriggerFileDownload).toHaveBeenCalledWith('https://example.com/file.exe', 'Decentraland-Installer.exe')
168+
})
169+
170+
it('should rethrow when the failure is due to the signal being aborted', async () => {
171+
const abortError = new Error('AbortError')
172+
abortError.name = 'AbortError'
173+
mockDownloadFileWithProgress.mockRejectedValue(abortError)
174+
abortController.abort()
175+
176+
await expect(
177+
streamOrFallback({
178+
url: 'https://example.com/file.exe',
179+
filename: 'Decentraland-Installer.exe',
180+
os: OperativeSystem.WINDOWS,
181+
signal: abortController.signal,
182+
onProgress
183+
})
184+
).rejects.toThrow('AbortError')
185+
186+
expect(mockTriggerFileDownload).not.toHaveBeenCalled()
187+
})
188+
})
189+
})
190+
})

src/modules/streamOrFallback.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { OperativeSystem } from '../types/download.types'
2+
import { downloadFileWithProgress, triggerFileDownload } from './file'
3+
import type { StreamOrFallbackArgs } from './streamOrFallback.types'
4+
5+
// Hold time when we can't observe the actual transfer — used on macOS (where
6+
// the native anchor is mandatory to preserve the kMDItemWhereFroms xattr the
7+
// launcher reads) and as the fetch-error fallback on Windows. The gateway's
8+
// per-request NSIS+sign step on Windows typically dominates and can run 5-30s,
9+
// so this is a coarse approximation rather than a real progress signal.
10+
const FALLBACK_LOADER_HOLD_MS = 4000
11+
12+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
13+
14+
/**
15+
* Triggers the installer download and resolves only when the file has
16+
* effectively landed (or the best approximation we have for the platform).
17+
*
18+
* Per-OS strategy:
19+
* - **Windows**: stream via fetch + Blob so the "Downloading..." UI stays
20+
* in sync with the actual transfer. Safe because attribution is baked
21+
* into the NSIS-wrapped EXE bytes (the wrapper writes
22+
* campaign-anon-user-id.txt during install) — a blob: URL changing the
23+
* Zone.Identifier doesn't affect the launcher's UUID recovery.
24+
* - **macOS**: native `<a>.click()` only. The launcher reads
25+
* `anon_user_id` from the DMG's `kMDItemWhereFroms` xattr that the OS
26+
* writes from the original download URL; a blob: URL would replace it
27+
* with the page's blob origin and break attribution. We fall back to a
28+
* fixed-duration backdrop hold instead.
29+
*
30+
* If the streamed fetch fails on Windows (CORS not configured for the
31+
* current origin — true on Vercel preview deploys, where the gateway only
32+
* allows known prod/staging hosts), fall back to the native anchor + hold
33+
* so the download still happens.
34+
*/
35+
const streamOrFallback = async ({ url, filename, os, signal, onProgress }: StreamOrFallbackArgs): Promise<void> => {
36+
if (os === OperativeSystem.MACOS) {
37+
triggerFileDownload(url, filename)
38+
await sleep(FALLBACK_LOADER_HOLD_MS)
39+
return
40+
}
41+
42+
try {
43+
await downloadFileWithProgress(
44+
url,
45+
filename,
46+
({ loaded, total }) => {
47+
if (signal.aborted || total <= 0) return
48+
// Cap at 99 so the UI shows "Downloading..." while we hand the blob
49+
// off to the anchor click; the caller flips to 100 once the click
50+
// has dispatched and the page transitions to the steps view.
51+
onProgress(Math.min(99, Math.floor((loaded / total) * 100)))
52+
},
53+
signal
54+
)
55+
} catch (error) {
56+
if (signal.aborted) throw error
57+
console.warn('Streamed download failed, falling back to native anchor', {
58+
name: error instanceof Error ? error.name : 'unknown'
59+
})
60+
triggerFileDownload(url, filename)
61+
await sleep(FALLBACK_LOADER_HOLD_MS)
62+
}
63+
}
64+
65+
export { FALLBACK_LOADER_HOLD_MS, streamOrFallback }
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { OperativeSystem } from '../types/download.types'
2+
3+
interface StreamOrFallbackArgs {
4+
url: string
5+
filename: string
6+
os: OperativeSystem
7+
signal: AbortSignal
8+
onProgress: (percent: number) => void
9+
}
10+
11+
export type { StreamOrFallbackArgs }

0 commit comments

Comments
 (0)