eb53de2ea5
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe (the bulk of its size). On first run they are downloaded from a pinned upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and unpacked with the OS tar.exe into the managed userData/bin dir -- the same place as the self-updating yt-dlp, so they survive app updates and portable re-extraction. A hard onboarding gate blocks the app until they are present, with a "locate existing ffmpeg" folder-picker fallback for offline machines and a Repair action in Settings > About. The updater's streaming/checksum/redirect/idle-timeout download loop is extracted to lib/verifiedDownload.streamVerifiedFile and shared by both the app-installer download and the ffmpeg fetch; the updater's public behaviour and error strings are unchanged (its boundary tests still pass). No new npm dependency: extraction uses the System32 bsdtar resolved by absolute path (audit F3). Note: this commit also carries pre-existing, in-progress aria2c/network and updater-token work that was already uncommitted in the working tree and is entangled with the above in shared files (updater.ts, ipc.ts, preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network files and aria2c.exe). It was not cleanly separable by path, so it is included here rather than split out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
import { net } from 'electron'
|
|
import { createWriteStream, type WriteStream } from 'fs'
|
|
import { unlink } from 'fs/promises'
|
|
import { createHash } from 'crypto'
|
|
|
|
/** Result of streaming a verified download to disk. */
|
|
export interface VerifiedDownloadResult {
|
|
ok: boolean
|
|
/** absolute path to the written file, when ok */
|
|
filePath?: string
|
|
error?: string
|
|
}
|
|
|
|
/** Live byte progress of a streaming download (main → renderer). */
|
|
export interface VerifiedDownloadProgress {
|
|
/** bytes received so far */
|
|
received: number
|
|
/** total bytes, when the server reports Content-Length */
|
|
total?: number
|
|
/** 0..1 fraction, when total is known */
|
|
fraction?: number
|
|
}
|
|
|
|
/** Context-specific message overrides; the defaults are generic and suit any caller. */
|
|
export interface VerifiedDownloadMessages {
|
|
/** shown when the streamed bytes don't match expectedSha */
|
|
checksumMismatch?: string
|
|
/** shown when the caller-armed cancel fires */
|
|
canceled?: string
|
|
}
|
|
|
|
export interface StreamVerifiedFileOptions {
|
|
url: string
|
|
filePath: string
|
|
/** published SHA-256 (lowercase hex) to verify against, or null to skip verification */
|
|
expectedSha: string | null
|
|
/** per-hop trust gate: a URL is fetched or followed only if this returns true for it */
|
|
isTrusted: (url: string) => boolean
|
|
onProgress: (p: VerifiedDownloadProgress) => void
|
|
onCancelReady: (cancel: () => void) => void
|
|
onSettled: (cancel: () => void) => void
|
|
/** stall budget: how long with no bytes received before abandoning (default 60s) */
|
|
idleTimeoutMs?: number
|
|
messages?: VerifiedDownloadMessages
|
|
}
|
|
|
|
/** Default stall (no-bytes) budget before a download is abandoned. */
|
|
const DEFAULT_IDLE_TIMEOUT_MS = 60_000
|
|
|
|
/**
|
|
* Stream one HTTPS resource into a file with full download discipline: per-hop redirect
|
|
* re-validation against a caller-supplied trust gate, streaming SHA-256 verification,
|
|
* idle-timeout, content-length truncation detection, write-stream backpressure, and a
|
|
* single teardown point that aborts the request and removes the partial file on every
|
|
* failure path.
|
|
*
|
|
* Extracted from the app updater (CL4) so the same audited loop backs both the installer
|
|
* download and the first-run ffmpeg fetch; the differences (which hosts are trusted, the
|
|
* checksum source, a couple of user-facing strings) are parameters, not forks. net.request
|
|
* (not fetch) is used so the host can be re-validated on EVERY redirect hop — undici's
|
|
* fetch hides the Location header under redirect:'manual', so it can't gate hops.
|
|
*
|
|
* The caller owns the cancel slot: `onCancelReady` hands it this download's cancel
|
|
* function once armed, and `onSettled` hands the same function back on completion so the
|
|
* caller can release the slot only if this download still owns it.
|
|
*/
|
|
export function streamVerifiedFile(
|
|
opts: StreamVerifiedFileOptions
|
|
): Promise<VerifiedDownloadResult> {
|
|
const { url, filePath, expectedSha, isTrusted, onProgress, onCancelReady, onSettled } = opts
|
|
const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
const canceledMsg = opts.messages?.canceled ?? 'Download canceled.'
|
|
const checksumMsg =
|
|
opts.messages?.checksumMismatch ??
|
|
'The download failed its checksum check — it may be corrupt or tampered with.'
|
|
|
|
return new Promise<VerifiedDownloadResult>((resolve) => {
|
|
let settled = false
|
|
let fileStream: WriteStream | null = null
|
|
let idle: NodeJS.Timeout | null = null
|
|
|
|
// Single teardown point. On failure it also aborts the request, so a write error
|
|
// (disk full, etc.) can't leave Electron pulling bytes into a dead stream. abort()
|
|
// is idempotent, so the call sites below don't repeat it.
|
|
const finish = (result: VerifiedDownloadResult): void => {
|
|
if (settled) return
|
|
settled = true
|
|
onSettled(requestCancel)
|
|
if (idle) clearTimeout(idle)
|
|
if (!result.ok) {
|
|
request.abort()
|
|
// Close the handle before unlinking (Windows won't delete an open file) and
|
|
// never leave a partial/aborted file lying around.
|
|
if (fileStream && !fileStream.destroyed) fileStream.destroy()
|
|
unlink(filePath).catch(() => {})
|
|
}
|
|
resolve(result)
|
|
}
|
|
|
|
// Expose this download's abort so the caller's Cancel routes through the same
|
|
// teardown — request.abort() + partial cleanup.
|
|
const requestCancel = (): void => finish({ ok: false, error: canceledMsg })
|
|
onCancelReady(requestCancel)
|
|
|
|
// 'manual' means a hop only proceeds if we call followRedirect().
|
|
const request = net.request({ url, redirect: 'manual' })
|
|
|
|
const armIdle = (): void => {
|
|
if (idle) clearTimeout(idle)
|
|
idle = setTimeout(() => {
|
|
finish({ ok: false, error: 'Download stalled — please try again.' })
|
|
}, idleTimeoutMs)
|
|
}
|
|
|
|
request.on('redirect', (_status, _method, redirectUrl) => {
|
|
if (!isTrusted(redirectUrl)) {
|
|
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
|
|
return
|
|
}
|
|
request.followRedirect()
|
|
})
|
|
|
|
request.on('response', (response) => {
|
|
const status = response.statusCode
|
|
if (status < 200 || status >= 300) {
|
|
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
|
|
return
|
|
}
|
|
|
|
const lenHeader = response.headers['content-length']
|
|
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
|
|
|
|
const stream = createWriteStream(filePath)
|
|
fileStream = stream
|
|
// Hash the bytes as they stream by, so verification needs no second pass.
|
|
const hash = createHash('sha256')
|
|
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume() exist at
|
|
// runtime but aren't in its type, so feature-detect them to apply backpressure —
|
|
// without it a large download can buffer in memory.
|
|
const flow = response as unknown as { pause?(): void; resume?(): void }
|
|
let received = 0
|
|
armIdle()
|
|
|
|
response.on('data', (chunk: Buffer) => {
|
|
armIdle()
|
|
received += chunk.length
|
|
hash.update(chunk)
|
|
if (!stream.write(chunk) && flow.pause && flow.resume) {
|
|
flow.pause()
|
|
stream.once('drain', () => flow.resume?.())
|
|
}
|
|
onProgress({ received, total, fraction: total ? received / total : undefined })
|
|
})
|
|
|
|
response.on('end', () => {
|
|
// Body fully received — "stalled" is no longer meaningful past this point.
|
|
if (idle) clearTimeout(idle)
|
|
stream.end(() => {
|
|
// A truncated download (connection dropped mid-stream) must never be used.
|
|
if (total !== undefined && received !== total) {
|
|
finish({ ok: false, error: 'The download was incomplete — please try again.' })
|
|
return
|
|
}
|
|
// Verify the bytes we wrote match the published SHA-256.
|
|
if (expectedSha && hash.digest('hex') !== expectedSha) {
|
|
finish({ ok: false, error: checksumMsg })
|
|
return
|
|
}
|
|
finish({ ok: true, filePath })
|
|
})
|
|
})
|
|
|
|
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
|
|
// promise can't hang.
|
|
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
|
|
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
|
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
|
})
|
|
|
|
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
|
request.end()
|
|
})
|
|
}
|