Files
AeroFetch/src/main/updater.ts
T
debont80 1376c2dee8 Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified —
typecheck + 234 tests + eslint + prettier green).

Correctness / bugs:
- B3: match the release checksum to the asset's filename line (no wrong-hash verify)
- B4: newline-safe metadata probe (one --print with a unit-separator delimiter)
- B5 / L88: guard the meta event against canceled items; progress no longer promotes
  a queued item outside pump()
- B7: cookie-login promise always resolves (handles destroy-without-close)
- L146: trim parser rejects >2 colon-group times; M36: Library selection counts only
  actionable rows
- L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message,
  schedule picker min, dead-code/comment cleanup

Type safety:
- Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed)

Resilience / Windows / metadata:
- R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth)
- W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window;
  L147 dead macOS branches removed
- CL1: shared stdout markers; package/builder metadata (license, homepage, repository,
  copyright, tsbuildinfo glob)

Copy / docs / tests:
- M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled
- New unit tests for L35 (isValidMediaItem) and L36 (compareVersions)

This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard
work it builds on: background running + auto-download, library clipboard detection,
tray, binary management & library scale, credential encryption at rest, the shared
jsonStore and ui/ primitives, and the eslint/prettier tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:02:54 -04:00

440 lines
18 KiB
TypeScript

import { app, net, shell, type WebContents } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { stat, unlink } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { createHash } from 'crypto'
import { getSettings } from './settings'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress
} from '@shared/ipc'
/**
* Authorization header for the update host. Empty unless the user has set an
* updateToken in Settings — needed when the release repo is private or the Gitea
* instance requires sign-in for anonymous access (the default on this instance).
*
* The token must never reach an origin other than the host-pinned UPDATE_HOST.
* Every request that carries it guards against that on its own terms: the REST
* check refuses to follow redirects (`redirect: 'error'`), and the download paths
* use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So
* a redirect can't bounce the header to another host on any path.
*/
function authHeader(): Record<string, string> {
const tok = getSettings().updateToken?.trim()
return tok ? { Authorization: `token ${tok}` } : {}
}
// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
// the repo's latest release over the public REST API and downloads the installer
// asset attached to it.
//
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
// AeroFetch never ships a token; on a sign-in-required instance the check simply
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
const UPDATE_OWNER = 'debont80'
const UPDATE_REPO = 'AeroFetch'
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
// Security: only ever download or execute a file served by the trusted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
// release downloads to its attachment store — so this is re-checked on EVERY
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
// response from bouncing us off the trusted host.
export function isTrustedDownloadUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
} catch {
return false
}
}
/** Where downloaded installers are written (and the only dir we'll run one from). */
function updateDir(): string {
return app.getPath('temp')
}
/**
* Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
* release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
* the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
* release (0.5.0): at most it compares equal, so we won't offer a prerelease as
* an "upgrade" over the same shipped version.
*/
function parseVersion(v: string): number[] {
// split() always yields at least one element; `?? ''` only satisfies the type
// checker (noUncheckedIndexedAccess) for the [0] access.
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
return core
.split('.')
.map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n))
}
/** Component-wise numeric compare: -1 if a<b, 0 if equal, 1 if a>b. */
export function compareVersions(a: string, b: string): number {
const pa = parseVersion(a)
const pb = parseVersion(b)
const len = Math.max(pa.length, pb.length)
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0
const y = pb[i] ?? 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
interface GiteaAsset {
name: string
browser_download_url: string
}
interface GiteaRelease {
tag_name: string
body?: string
html_url?: string
assets?: GiteaAsset[]
}
/**
* Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
* lowercase digest, or null if there's no standalone 64-char hex token (so a
* longer run like a sha512 digest is ignored rather than sliced).
*
* When `fileName` is given (the installer asset's name), a multi-line / combined
* checksum file is matched line-by-line and the hash on the line naming THIS
* asset wins — so a `<asset>.sha256` that happens to list several files can't
* verify the installer against the wrong line's hash (B3). Falls back to the
* first standalone digest for bare single-hash files (no filename present).
*/
export function extractSha256(text: string, fileName?: string): string | null {
const hashRe = /\b[a-f0-9]{64}\b/i
if (fileName) {
const base = fileName.toLowerCase()
for (const line of text.split(/\r?\n/)) {
const m = line.match(hashRe)
if (!m) continue
// The filename is the last whitespace-delimited token on a sha256sum /
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
if (last.toLowerCase() === base) return m[0].toLowerCase()
}
}
const m = text.match(hashRe)
return m ? m[0].toLowerCase() : null
}
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
const exes = assets.filter((a) => /\.exe$/i.test(a.name))
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
}
/** Query the configured repo's latest release and compare it to the running app. */
export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const currentVersion = app.getVersion()
// Bound the check so a stalled/unreachable server can't hang the UI spinner
// indefinitely — mirrors the timeouts on the yt-dlp calls.
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json', ...authHeader() },
// The API lives at an exact pinned URL and answers 200 directly. Refusing
// redirects keeps an authenticated check from ever forwarding the token to
// another origin; a stray redirect fails safe into the catch below.
redirect: 'error',
signal: controller.signal
})
if (!res.ok) {
const auth = res.status === 401 || res.status === 403 || res.status === 404
return {
ok: false,
available: false,
currentVersion,
error: auth
? 'Could not reach the update server (it may require sign-in for anonymous access).'
: `Update check failed (HTTP ${res.status}).`
}
}
const rel = (await res.json()) as GiteaRelease
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
const asset = pickInstaller(rel.assets ?? [])
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
return {
ok: true,
available,
currentVersion,
latestVersion,
notes: rel.body?.trim() || undefined,
// Only ever hand the OS browser a release page on our own trusted host.
htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
downloadUrl: asset?.browser_download_url,
assetName: asset?.name
}
} catch (e) {
const timedOut = e instanceof Error && e.name === 'AbortError'
return {
ok: false,
available: false,
currentVersion,
error: timedOut
? 'Update check timed out — the server took too long to respond.'
: e instanceof Error
? e.message
: String(e)
}
} finally {
clearTimeout(timer)
}
}
/** How long the download may stall (no bytes received) before we give up. */
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
// to run an installer we can't verify against it. Note this is INTEGRITY +
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
// bounced to other storage), NOT protection against a fully compromised host —
// that host serves both files, so only Authenticode signing defends against it.
const REQUIRE_CHECKSUM: boolean = true
/**
* GET a small text resource from the trusted host, re-validating the host on
* every redirect hop (same discipline as the installer download) and capping the
* body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
*/
function fetchTrustedText(
url: string,
maxBytes = 64 * 1024,
timeoutMs = 15_000
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
return new Promise((resolve) => {
let settled = false
const done = (
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
): void => {
if (settled) return
settled = true
clearTimeout(timer)
if (!r.ok) request.abort()
resolve(r)
}
const request = net.request({ url, redirect: 'manual' })
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
request.on('redirect', (_s, _m, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
done({ ok: false, error: 'redirect to an untrusted location' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
done({ ok: false, status, error: `HTTP ${status}` })
return
}
const chunks: Buffer[] = []
let size = 0
response.on('data', (c: Buffer) => {
size += c.length
if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
else chunks.push(c)
})
response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
response.on('error', (e: Error) => done({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => done({ ok: false, error: e.message }))
request.end()
})
}
/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(url: string, wc: WebContents): Promise<AppUpdateDownload> {
if (!isTrustedDownloadUrl(url)) {
return { ok: false, error: 'Refused to download from an untrusted location.' }
}
// Derive a safe .exe filename from the URL; fall back to a fixed name.
const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe'
const filePath = join(updateDir(), safeName)
// Resolve the published checksum up front (tiny, fails fast) so we never pull a
// ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
// so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
const checksumUrl = (() => {
const u = new URL(url)
u.pathname += '.sha256'
return u.toString()
})()
const sum = await fetchTrustedText(checksumUrl)
let expectedSha: string | null = null
if (sum.ok) {
expectedSha = extractSha256(sum.text, safeName)
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
} else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) {
return {
ok: false,
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
}
}
} else {
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
}
return new Promise<AppUpdateDownload>((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: AppUpdateDownload): void => {
if (settled) return
settled = true
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 installer lying around in temp.
if (fileStream && !fileStream.destroyed) fileStream.destroy()
unlink(filePath).catch(() => {})
}
resolve(result)
}
// net.request (not fetch) so we can re-validate the host on EVERY redirect
// hop: Gitea 302s a release download to its attachment store, and undici's
// fetch hides the Location header under redirect:'manual', so it can't gate
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
const request = net.request({ url, redirect: 'manual' })
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
const armIdle = (): void => {
if (idle) clearTimeout(idle)
idle = setTimeout(() => {
finish({ ok: false, error: 'Download stalled — please try again.' })
}, DOWNLOAD_IDLE_TIMEOUT_MS)
}
request.on('redirect', (_status, _method, redirectUrl) => {
if (!isTrustedDownloadUrl(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 ~300 MB installer 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?.())
}
if (!wc.isDestroyed()) {
const progress: AppUpdateProgress = {
received,
total,
fraction: total ? received / total : undefined
}
wc.send(IpcChannels.appUpdateProgress, progress)
}
})
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 run.
if (total !== undefined && received !== total) {
finish({ ok: false, error: 'The download was incomplete — please try again.' })
return
}
// Verify the bytes we wrote match the release's published SHA-256.
if (expectedSha && hash.digest('hex') !== expectedSha) {
finish({
ok: false,
error:
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
})
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()
})
}
/** Let the launched installer spawn before we quit to release our files (ms). */
const INSTALLER_HANDOFF_MS = 1500
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
try {
// Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir,
// never an arbitrary path handed over IPC. Compare the parent directory by
// equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
const expectedDir = normalize(updateDir()).toLowerCase()
const target = normalize(filePath)
if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
return { ok: false, error: 'Refused to run an unexpected file.' }
}
await stat(target) // throws if the file is missing
const err = await shell.openPath(target) // hand the installer to the OS
if (err) return { ok: false, error: err }
// Give the installer a beat to spawn, then quit so it can overwrite our files.
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}