Skip to content

Commit b00301d

Browse files
feat: adapt macos download hold to file size and connection speed (#413)
* feat: adapt macos download hold to file size and connection speed * fix: address review — abort-aware sleep, lower max hold, inline helpers, more tests
1 parent 58afd21 commit b00301d

2 files changed

Lines changed: 287 additions & 28 deletions

File tree

src/modules/streamOrFallback.test.ts

Lines changed: 178 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { OperativeSystem } from '../types/download.types'
2-
import { FALLBACK_LOADER_HOLD_MS, streamOrFallback } from './streamOrFallback'
2+
import {
3+
ESTIMATE_MAX_HOLD_MS,
4+
ESTIMATE_MIN_HOLD_MS,
5+
FALLBACK_LOADER_HOLD_MS,
6+
estimateDownloadHoldMs,
7+
streamOrFallback
8+
} from './streamOrFallback'
39

410
const mockTriggerFileDownload = jest.fn()
511
const mockDownloadFileWithProgress = jest.fn()
@@ -9,20 +15,136 @@ jest.mock('./file', () => ({
915
downloadFileWithProgress: (...args: unknown[]) => mockDownloadFileWithProgress(...args)
1016
}))
1117

18+
const buildHeadResponse = (contentLength: string | null, ok = true): Response =>
19+
({
20+
ok,
21+
headers: { get: (name: string) => (name.toLowerCase() === 'content-length' ? contentLength : null) }
22+
}) as unknown as Response
23+
24+
const setNavigatorConnection = (downlink: number | undefined): void => {
25+
Object.defineProperty(window.navigator, 'connection', {
26+
configurable: true,
27+
value: downlink !== undefined ? { downlink } : undefined
28+
})
29+
}
30+
31+
const clearNavigatorConnection = (): void => {
32+
Object.defineProperty(window.navigator, 'connection', { configurable: true, value: undefined })
33+
}
34+
35+
describe('estimateDownloadHoldMs', () => {
36+
let mockFetch: jest.Mock
37+
38+
beforeEach(() => {
39+
mockFetch = jest.fn()
40+
global.fetch = mockFetch as unknown as typeof fetch
41+
})
42+
43+
afterEach(() => {
44+
jest.resetAllMocks()
45+
clearNavigatorConnection()
46+
})
47+
48+
describe('when the HEAD request returns a Content-Length and the browser exposes downlink', () => {
49+
beforeEach(() => {
50+
// 80 MB in bytes — the macOS DMG ballpark.
51+
mockFetch.mockResolvedValue(buildHeadResponse(String(80 * 1024 * 1024)))
52+
})
53+
54+
it('should compute a hold proportional to size / downlink with the safety margin applied', async () => {
55+
// 100 Mbps → 80MB ≈ 6.4s pure transfer + safety margin (~1.5s) → ~7.9s.
56+
setNavigatorConnection(100)
57+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
58+
expect(ms).toBeGreaterThanOrEqual(7000)
59+
expect(ms).toBeLessThanOrEqual(9000)
60+
})
61+
62+
it('should produce a longer hold for a slower connection at the same size', async () => {
63+
// 5 Mbps → 80MB ≈ 128s pure transfer, way over ESTIMATE_MAX_HOLD_MS.
64+
setNavigatorConnection(5)
65+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
66+
expect(ms).toBe(ESTIMATE_MAX_HOLD_MS)
67+
})
68+
})
69+
70+
describe('when the HEAD request fails', () => {
71+
it('should return the minimum hold instead of guessing', async () => {
72+
mockFetch.mockRejectedValue(new TypeError('CORS blocked'))
73+
setNavigatorConnection(50)
74+
75+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
76+
77+
expect(ms).toBe(ESTIMATE_MIN_HOLD_MS)
78+
})
79+
})
80+
81+
describe('when the HEAD response is non-2xx (403, 405, etc.)', () => {
82+
it('should treat it like a missing Content-Length and return the minimum hold', async () => {
83+
mockFetch.mockResolvedValue(buildHeadResponse(String(80 * 1024 * 1024), false))
84+
setNavigatorConnection(50)
85+
86+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
87+
88+
expect(ms).toBe(ESTIMATE_MIN_HOLD_MS)
89+
})
90+
})
91+
92+
describe('when the HEAD response omits Content-Length', () => {
93+
it('should return the minimum hold', async () => {
94+
mockFetch.mockResolvedValue(buildHeadResponse(null))
95+
setNavigatorConnection(50)
96+
97+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
98+
99+
expect(ms).toBe(ESTIMATE_MIN_HOLD_MS)
100+
})
101+
})
102+
103+
describe('when the browser does not expose navigator.connection (Safari, Firefox)', () => {
104+
it('should fall back to the default downlink and still produce an adaptive hold', async () => {
105+
// 10 MB at the default 25 Mbps ≈ 3.2s pure transfer + safety margin.
106+
// Sized to land between MIN and MAX so the assertion exercises the
107+
// adaptive arithmetic rather than the clamps.
108+
mockFetch.mockResolvedValue(buildHeadResponse(String(10 * 1024 * 1024)))
109+
clearNavigatorConnection()
110+
111+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
112+
113+
expect(ms).toBeGreaterThan(ESTIMATE_MIN_HOLD_MS)
114+
expect(ms).toBeLessThan(ESTIMATE_MAX_HOLD_MS)
115+
})
116+
})
117+
118+
describe('when the file is tiny', () => {
119+
it('should clamp to the minimum hold so the modal does not vanish instantly', async () => {
120+
mockFetch.mockResolvedValue(buildHeadResponse('512')) // 512 bytes
121+
setNavigatorConnection(100)
122+
123+
const ms = await estimateDownloadHoldMs('https://example.com/file.dmg')
124+
125+
expect(ms).toBe(ESTIMATE_MIN_HOLD_MS)
126+
})
127+
})
128+
})
129+
12130
describe('streamOrFallback', () => {
13131
let abortController: AbortController
14132
let onProgress: jest.Mock
133+
let mockFetch: jest.Mock
15134

16135
beforeEach(() => {
17-
jest.useFakeTimers()
136+
jest.useFakeTimers({ doNotFake: ['performance', 'Date'] })
18137
abortController = new AbortController()
19138
onProgress = jest.fn()
139+
mockFetch = jest.fn().mockResolvedValue(buildHeadResponse(null))
140+
global.fetch = mockFetch as unknown as typeof fetch
20141
})
21142

22143
afterEach(() => {
23144
jest.runOnlyPendingTimers()
24145
jest.useRealTimers()
25146
jest.resetAllMocks()
147+
clearNavigatorConnection()
26148
})
27149

28150
describe('when the OS is macOS', () => {
@@ -35,14 +157,32 @@ describe('streamOrFallback', () => {
35157
onProgress
36158
})
37159

38-
jest.advanceTimersByTime(FALLBACK_LOADER_HOLD_MS)
160+
// Drain the HEAD round-trip and the timer.
161+
await jest.advanceTimersByTimeAsync(ESTIMATE_MAX_HOLD_MS)
39162
await promise
40163

41164
expect(mockTriggerFileDownload).toHaveBeenCalledWith('https://example.com/file.dmg', 'Decentraland-Installer.dmg')
42165
expect(mockDownloadFileWithProgress).not.toHaveBeenCalled()
43166
})
44167

45-
it('should hold the backdrop for FALLBACK_LOADER_HOLD_MS before resolving', async () => {
168+
it('should HEAD the URL to get Content-Length for the adaptive estimate', async () => {
169+
const promise = streamOrFallback({
170+
url: 'https://example.com/file.dmg',
171+
filename: 'Decentraland-Installer.dmg',
172+
os: OperativeSystem.MACOS,
173+
signal: abortController.signal,
174+
onProgress
175+
})
176+
177+
await jest.advanceTimersByTimeAsync(ESTIMATE_MAX_HOLD_MS)
178+
await promise
179+
180+
expect(mockFetch).toHaveBeenCalledWith('https://example.com/file.dmg', expect.objectContaining({ method: 'HEAD' }))
181+
})
182+
183+
it('should hold for at least ESTIMATE_MIN_HOLD_MS when HEAD provides no size signal', async () => {
184+
mockFetch.mockResolvedValue(buildHeadResponse(null))
185+
46186
let resolved = false
47187
streamOrFallback({
48188
url: 'https://example.com/file.dmg',
@@ -54,12 +194,41 @@ describe('streamOrFallback', () => {
54194
resolved = true
55195
})
56196

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)
197+
await jest.advanceTimersByTimeAsync(ESTIMATE_MIN_HOLD_MS - 1)
60198
expect(resolved).toBe(false)
61199

62-
await jest.advanceTimersByTimeAsync(1)
200+
await jest.advanceTimersByTimeAsync(2)
201+
expect(resolved).toBe(true)
202+
})
203+
204+
it('should resolve promptly when the signal aborts mid-hold', async () => {
205+
// Long estimated hold so the test exercises a real wait window:
206+
// 80 MB at 5 Mbps caps at MAX_HOLD_MS.
207+
mockFetch.mockResolvedValue(buildHeadResponse(String(80 * 1024 * 1024)))
208+
setNavigatorConnection(5)
209+
210+
let resolved = false
211+
streamOrFallback({
212+
url: 'https://example.com/file.dmg',
213+
filename: 'Decentraland-Installer.dmg',
214+
os: OperativeSystem.MACOS,
215+
signal: abortController.signal,
216+
onProgress
217+
}).then(() => {
218+
resolved = true
219+
})
220+
221+
// Let the HEAD resolve and the sleep get scheduled, then verify we are
222+
// mid-hold (not yet resolved).
223+
await jest.advanceTimersByTimeAsync(ESTIMATE_MIN_HOLD_MS)
224+
expect(resolved).toBe(false)
225+
226+
// Abort while still inside the hold.
227+
abortController.abort()
228+
await jest.advanceTimersByTimeAsync(0)
229+
230+
// Should resolve now without needing to advance through the rest of
231+
// the long hold.
63232
expect(resolved).toBe(true)
64233
})
65234
})
@@ -158,10 +327,7 @@ describe('streamOrFallback', () => {
158327
onProgress
159328
})
160329

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)
330+
await jest.advanceTimersByTimeAsync(FALLBACK_LOADER_HOLD_MS)
165331
await promise
166332

167333
expect(mockTriggerFileDownload).toHaveBeenCalledWith('https://example.com/file.exe', 'Decentraland-Installer.exe')

src/modules/streamOrFallback.ts

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,104 @@ import { OperativeSystem } from '../types/download.types'
22
import { downloadFileWithProgress, triggerFileDownload } from './file'
33
import type { StreamOrFallbackArgs } from './streamOrFallback.types'
44

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.
5+
// Fallback hold for the Windows fetch-error path. The HEAD that powers the
6+
// macOS adaptive estimate would likely fail for the same reason the streamed
7+
// fetch did (CORS, network), so we hardcode a coarse approximation here
8+
// instead of trying twice. The native anchor takes over the actual save and
9+
// the browser's download manager remains visible to the user as the real
10+
// progress signal.
1011
const FALLBACK_LOADER_HOLD_MS = 4000
1112

12-
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
13+
// Bandwidth assumption when the browser doesn't expose `navigator.connection`
14+
// (Safari, Firefox). Conservative middle of "typical broadband" so fast users
15+
// see the modal close a bit late and slow users see it close a bit early —
16+
// the alternative (no signal at all) is worse than an imperfect estimate.
17+
const DEFAULT_DOWNLINK_MBPS = 25
18+
19+
// Wall-clock margin added on top of the pure transfer estimate to absorb
20+
// connection setup, HTTP/TLS handshake, server-side processing, and the
21+
// browser's write-to-disk after the bytes arrive. Picked empirically from
22+
// typical Mac DMG downloads.
23+
const ESTIMATE_SAFETY_MARGIN_MS = 1500
24+
25+
// Always hold for at least this long so the modal doesn't blink-and-vanish
26+
// on cache hits / instant downloads.
27+
const ESTIMATE_MIN_HOLD_MS = 2000
28+
29+
// Never hang a static (no progress bar) modal beyond this. Past this point
30+
// the browser's own download manager already shows as the user-facing
31+
// progress signal — keeping our modal up longer would just look stuck.
32+
const ESTIMATE_MAX_HOLD_MS = 20000
33+
34+
interface NavigatorConnectionLike {
35+
downlink?: number
36+
}
37+
38+
/**
39+
* Sleeps for `ms` milliseconds, resolving early if `signal` aborts. Resolves
40+
* (rather than rejecting) on abort so callers can decide whether the caller
41+
* was already past the point of caring — e.g. the download has already been
42+
* dispatched to the OS, so an aborted hold just means "stop waiting and go
43+
* tear down the UI".
44+
*/
45+
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
46+
new Promise(resolve => {
47+
if (signal?.aborted) return resolve()
48+
const id = setTimeout(resolve, ms)
49+
signal?.addEventListener(
50+
'abort',
51+
() => {
52+
clearTimeout(id)
53+
resolve()
54+
},
55+
{ once: true }
56+
)
57+
})
58+
59+
const fetchContentLength = async (url: string, signal?: AbortSignal): Promise<number | null> => {
60+
try {
61+
const response = await fetch(url, {
62+
method: 'HEAD',
63+
mode: 'cors',
64+
credentials: 'omit',
65+
cache: 'no-store',
66+
signal
67+
})
68+
if (!response.ok) return null
69+
const header = response.headers.get('content-length')
70+
if (!header) return null
71+
const size = parseInt(header, 10)
72+
return Number.isFinite(size) && size > 0 ? size : null
73+
} catch {
74+
return null
75+
}
76+
}
77+
78+
/**
79+
* Estimates how long the modal should stay open given the file size and
80+
* the browser's reported connection speed. Adaptive per-user instead of a
81+
* single hardcoded constant. Returns the clamped hold in milliseconds.
82+
*
83+
* - Reads `Content-Length` from a HEAD request to the gateway.
84+
* - Reads `navigator.connection.downlink` (Mbps) when available; falls back
85+
* to a conservative default on Safari and Firefox where the API doesn't
86+
* exist.
87+
* - Adds a margin for handshake / write-to-disk variance and clamps the
88+
* result so the modal can't disappear instantly nor hang forever.
89+
*/
90+
const estimateDownloadHoldMs = async (url: string, signal?: AbortSignal): Promise<number> => {
91+
const sizeBytes = await fetchContentLength(url, signal)
92+
if (sizeBytes === null) return ESTIMATE_MIN_HOLD_MS
93+
94+
const connection =
95+
typeof navigator !== 'undefined' ? (navigator as Navigator & { connection?: NavigatorConnectionLike }).connection : undefined
96+
const downlinkMbps = connection?.downlink && connection.downlink > 0 ? connection.downlink : DEFAULT_DOWNLINK_MBPS
97+
98+
const downlinkBytesPerSec = (downlinkMbps * 1024 * 1024) / 8
99+
const transferMs = (sizeBytes / downlinkBytesPerSec) * 1000
100+
const total = transferMs + ESTIMATE_SAFETY_MARGIN_MS
101+
return Math.min(ESTIMATE_MAX_HOLD_MS, Math.max(ESTIMATE_MIN_HOLD_MS, total))
102+
}
13103

14104
/**
15105
* Triggers the installer download and resolves only when the file has
@@ -21,21 +111,24 @@ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(r
21111
* into the NSIS-wrapped EXE bytes (the wrapper writes
22112
* campaign-anon-user-id.txt during install) — a blob: URL changing the
23113
* 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.
114+
* - **macOS**: native `<a>.click()` (the launcher reads `anon_user_id`
115+
* from the DMG's `kMDItemWhereFroms` xattr and a blob: URL would replace
116+
* it with the page's blob origin) plus an adaptive hold based on
117+
* `Content-Length` from a HEAD request and `navigator.connection.downlink`.
29118
*
30119
* If the streamed fetch fails on Windows (CORS not configured for the
31120
* 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.
121+
* allows known prod/staging hosts), fall back to the native anchor + a
122+
* fixed hold so the download still happens.
34123
*/
35124
const streamOrFallback = async ({ url, filename, os, signal, onProgress }: StreamOrFallbackArgs): Promise<void> => {
36125
if (os === OperativeSystem.MACOS) {
126+
const startedAt = Date.now()
37127
triggerFileDownload(url, filename)
38-
await sleep(FALLBACK_LOADER_HOLD_MS)
128+
const holdMs = await estimateDownloadHoldMs(url, signal)
129+
if (signal.aborted) return
130+
const remaining = Math.max(0, holdMs - (Date.now() - startedAt))
131+
await sleep(remaining, signal)
39132
return
40133
}
41134

@@ -58,8 +151,8 @@ const streamOrFallback = async ({ url, filename, os, signal, onProgress }: Strea
58151
name: error instanceof Error ? error.name : 'unknown'
59152
})
60153
triggerFileDownload(url, filename)
61-
await sleep(FALLBACK_LOADER_HOLD_MS)
154+
await sleep(FALLBACK_LOADER_HOLD_MS, signal)
62155
}
63156
}
64157

65-
export { FALLBACK_LOADER_HOLD_MS, streamOrFallback }
158+
export { ESTIMATE_MAX_HOLD_MS, ESTIMATE_MIN_HOLD_MS, FALLBACK_LOADER_HOLD_MS, estimateDownloadHoldMs, streamOrFallback }

0 commit comments

Comments
 (0)