diff --git a/src/main/index.ts b/src/main/index.ts index 6305fe0..fc0184c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ import { type SystemThemeInfo } from '@shared/ipc' import { getYtdlpVersion, updateYtdlp } from './ytdlp' +import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { probeMedia } from './probe' import { startDownload, cancelDownload, previewCommand } from './download' import { getSettings, setSettings, ensureMediaDirs } from './settings' @@ -181,6 +182,12 @@ function createWindow(): void { function registerIpcHandlers(): void { ipcMain.handle(IpcChannels.appVersion, () => app.getVersion()) + ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate()) + ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) => + downloadAppUpdate(url, e.sender) + ) + ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath)) + ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..1b4e940 --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,394 @@ +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 { + IpcChannels, + type AppUpdateInfo, + type AppUpdateDownload, + type AppUpdateProgress +} from '@shared/ipc' + +// --- 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[] { + return v + .replace(/^v/i, '') + .split(/[-+]/)[0] + .split('.') + .map((p) => parseInt(p, 10)) + .filter((n) => !Number.isNaN(n)) +} + +/** Component-wise numeric compare: -1 if ab. */ +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 (` 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). + */ +export function extractSha256(text: string): string | null { + const m = text.match(/\b[a-f0-9]{64}\b/i) + 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 { + 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' }, + 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 `.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' }) + 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 { + 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) + 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((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' }) + + 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 `_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) } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index aa93a80..eb95485 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,9 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { IpcChannels, + type AppUpdateInfo, + type AppUpdateDownload, + type AppUpdateProgress, type YtdlpVersionResult, type YtdlpUpdateChannel, type YtdlpUpdateResult, @@ -31,6 +34,24 @@ const api = { /** AeroFetch's own version string (e.g. '0.3.1'). */ getAppVersion: (): Promise => ipcRenderer.invoke(IpcChannels.appVersion), + /** Check the configured Gitea repo for a newer AeroFetch release. */ + checkForAppUpdate: (): Promise => ipcRenderer.invoke(IpcChannels.appUpdateCheck), + + /** Download the latest release's installer to a temp file. */ + downloadAppUpdate: (url: string): Promise => + ipcRenderer.invoke(IpcChannels.appUpdateDownload, url), + + /** Launch a downloaded installer and quit so it can replace the app. */ + runAppUpdate: (filePath: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke(IpcChannels.appUpdateRun, filePath), + + /** Subscribe to installer download progress. Returns an unsubscribe function. */ + onAppUpdateProgress: (cb: (p: AppUpdateProgress) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, p: AppUpdateProgress): void => cb(p) + ipcRenderer.on(IpcChannels.appUpdateProgress, listener) + return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener) + }, + getYtdlpVersion: (): Promise => ipcRenderer.invoke(IpcChannels.ytdlpVersion), diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 889d490..8f52a91 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -10,6 +10,7 @@ import { Caption1, Spinner, Text, + ProgressBar, makeStyles, mergeClasses, tokens, @@ -31,13 +32,16 @@ import { CopyRegular, DeleteRegular, PaintBucketRegular, - AccessibilityRegular + AccessibilityRegular, + ArrowSyncRegular, + OpenRegular } from '@fluentui/react-icons' import { COOKIE_BROWSERS, type YtdlpVersionResult, type YtdlpUpdateChannel, type YtdlpUpdateResult, + type AppUpdateInfo, type MediaKind, type CookieSource, type CookieBrowser, @@ -71,6 +75,17 @@ const useStyles = makeStyles({ padding: '20px', ...shorthands.borderRadius(tokens.borderRadiusXLarge) }, + notesBox: { + maxHeight: '180px', + overflowY: 'auto', + padding: '10px 12px', + backgroundColor: tokens.colorNeutralBackground2, + ...shorthands.borderRadius(tokens.borderRadiusMedium), + border: `1px solid ${tokens.colorNeutralStroke2}`, + whiteSpace: 'pre-wrap', + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase300 + }, sectionHeader: { display: 'flex', alignItems: 'center', @@ -209,6 +224,14 @@ export function SettingsView(): React.JSX.Element { const [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState(null) + // --- AeroFetch app self-update --- + const [appVersion, setAppVersion] = useState('') + const [appUpd, setAppUpd] = useState(null) + const [appChecking, setAppChecking] = useState(false) + const [appDownloading, setAppDownloading] = useState(false) + const [appFraction, setAppFraction] = useState(undefined) + const [appUpdError, setAppUpdError] = useState(null) + const [cookiesStatus, setCookiesStatus] = useState(null) const [loginUrl, setLoginUrl] = useState('https://www.youtube.com') const [signingIn, setSigningIn] = useState(false) @@ -223,6 +246,49 @@ export function SettingsView(): React.JSX.Element { window.api.cookiesStatus().then(setCookiesStatus) }, []) + useEffect(() => { + window.api.getAppVersion().then(setAppVersion).catch(() => {}) + return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction)) + }, []) + + async function checkAppUpdate(): Promise { + setAppChecking(true) + setAppUpd(null) + setAppUpdError(null) + try { + const info = await window.api.checkForAppUpdate() + // Funnel a failed check into the single error slot rather than storing a + // second not-ok AppUpdateInfo, so only one error message can ever render. + if (info.ok) setAppUpd(info) + else setAppUpdError(info.error ?? 'Update check failed.') + } catch (e) { + setAppUpdError(e instanceof Error ? e.message : String(e)) + } finally { + setAppChecking(false) + } + } + + async function installAppUpdate(): Promise { + if (!appUpd?.downloadUrl) return + setAppDownloading(true) + setAppFraction(undefined) + setAppUpdError(null) + try { + const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl) + if (!dl.ok || !dl.filePath) { + setAppUpdError(dl.error ?? 'Download failed.') + return + } + const run = await window.api.runAppUpdate(dl.filePath) + // On success the app quits as the installer launches; only errors return here. + if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.') + } catch (e) { + setAppUpdError(e instanceof Error ? e.message : String(e)) + } finally { + setAppDownloading(false) + } + } + async function exportBackup(): Promise { setExporting(true) setExportResult(null) @@ -765,6 +831,69 @@ export function SettingsView(): React.JSX.Element { )} + +
+ + Software update +
+ + {appVersion + ? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.` + : 'Checking your AeroFetch version…'} + + +
+ + {appChecking && } +
+ + {appUpd?.ok && appUpd.available && ( + <> + + Version {appUpd.latestVersion} is available — here's what changed: + + {appUpd.notes &&
{appUpd.notes}
} +
+ + {appUpd.htmlUrl && ( + + )} +
+ {!appUpd.downloadUrl && ( + + This release has no installer attached — use “View release” to download it manually. + + )} + {appDownloading && } + + )} + + {appUpd?.ok && !appUpd.available && ( + You're up to date — v{appUpd.currentVersion} is the latest. + )} + + {appUpdError && ( + + {appUpdError} + + )} +
+
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 18700db..9362047 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -45,7 +45,27 @@ if (import.meta.env.DEV && !window.api) { ] window.api = { - getAppVersion: async () => '0.3.2-preview', + getAppVersion: async () => '0.4.0-preview', + checkForAppUpdate: async () => { + await new Promise((r) => setTimeout(r, 400)) + return { + ok: true, + available: true, + currentVersion: '0.4.0', + latestVersion: '0.5.0', + notes: + '### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes', + htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases', + downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe', + assetName: 'AeroFetch-Setup-0.5.0.exe' + } + }, + downloadAppUpdate: async () => ({ + ok: false, + error: 'Downloading updates is disabled in the browser preview.' + }), + runAppUpdate: async () => ({ ok: true }), + onAppUpdateProgress: () => () => {}, getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }), probe: async (url: string) => { await new Promise((r) => setTimeout(r, 700)) // simulate network latency diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index bc88370..100eaa2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -6,6 +6,14 @@ export const IpcChannels = { /** the AeroFetch app version (package.json / app.getVersion) */ appVersion: 'app:version', + /** check the configured Gitea repo for a newer AeroFetch release */ + appUpdateCheck: 'app:update-check', + /** download the latest release's installer to a temp file */ + appUpdateDownload: 'app:update-download', + /** launch a downloaded installer and quit so it can replace the app */ + appUpdateRun: 'app:update-run', + /** main → renderer push channel for installer download progress */ + appUpdateProgress: 'app:update-progress', ytdlpVersion: 'ytdlp:version', probe: 'media:probe', downloadStart: 'download:start', @@ -188,6 +196,45 @@ export interface YtdlpVersionResult { error?: string } +/** Result of checking the configured Gitea repo for a newer AeroFetch release. */ +export interface AppUpdateInfo { + ok: boolean + /** true when latestVersion is newer than the running app */ + available: boolean + /** the running app's version, e.g. '0.4.0' */ + currentVersion: string + /** the latest release's version (tag without a leading 'v'), when ok */ + latestVersion?: string + /** the release notes / changelog body (Markdown), shown before updating */ + notes?: string + /** the release's web page on Gitea (for "View release") */ + htmlUrl?: string + /** direct download URL of the Windows installer asset, when the release has one */ + downloadUrl?: string + /** the installer asset's file name */ + assetName?: string + /** human-readable error, when ok is false */ + error?: string +} + +/** Result of downloading the update installer to a temp file. */ +export interface AppUpdateDownload { + ok: boolean + /** absolute path to the downloaded installer, when ok */ + filePath?: string + error?: string +} + +/** Live progress of the installer download (main → renderer). */ +export interface AppUpdateProgress { + /** bytes downloaded so far */ + received: number + /** total bytes, when the server reports Content-Length */ + total?: number + /** 0..1 fraction, when total is known */ + fraction?: number +} + /** * yt-dlp's self-update release channels (`--update-to `). * diff --git a/test/security-guards.test.ts b/test/security-guards.test.ts index 3f2bf70..9cae025 100644 --- a/test/security-guards.test.ts +++ b/test/security-guards.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { assertHttpUrl } from '../src/main/url' import { isAllowedLoginUrl } from '../src/main/cookies' +import { isTrustedDownloadUrl, extractSha256, compareVersions } from '../src/main/updater' import { isYtdlpUpdateChannel } from '@shared/ipc' // --- F5: assertHttpUrl — argument-injection guard + normalisation ----------- @@ -68,6 +69,88 @@ describe('isAllowedLoginUrl (audit T4)', () => { }) }) +// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops ------- + +describe('isTrustedDownloadUrl (app-updater host pin)', () => { + const onHost = + 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe' + + it('accepts https URLs on the exact update host (incl. port)', () => { + expect(isTrustedDownloadUrl(onHost)).toBe(true) + }) + + it('rejects a different host — the redirect/MITM bounce vector', () => { + expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false) + }) + + it('rejects the right hostname on the wrong port', () => { + // host comparison includes the port, so :443 (default) is not the same origin + expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(false) + }) + + it('rejects plain http even on the update host', () => { + expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false) + }) + + it('is not fooled by the trusted host placed in the userinfo', () => { + expect( + isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe') + ).toBe(false) + }) + + it('rejects unparseable input', () => { + expect(isTrustedDownloadUrl('not a url')).toBe(false) + expect(isTrustedDownloadUrl('')).toBe(false) + }) +}) + +// --- app-updater: extractSha256 — checksum-file parsing ---------------------- + +describe('extractSha256 (app-updater checksum parsing)', () => { + const hash = 'a'.repeat(64) + + it('reads a bare lowercase digest', () => { + expect(extractSha256(hash)).toBe(hash) + }) + + it('reads sha256sum format (` filename`)', () => { + expect(extractSha256(`${hash} AeroFetch-Setup-0.5.0.exe\n`)).toBe(hash) + }) + + it('lowercases a PowerShell Get-FileHash (uppercase) digest', () => { + expect(extractSha256('A'.repeat(64))).toBe(hash) + }) + + it('returns null when there is no standalone 64-char hex token', () => { + expect(extractSha256('')).toBeNull() + expect(extractSha256('not a checksum')).toBeNull() + expect(extractSha256('deadbeef')).toBeNull() // too short + }) + + it('does not slice a 64-char window out of a longer hex run (e.g. sha512)', () => { + expect(extractSha256('a'.repeat(128))).toBeNull() + }) +}) + +// --- app-updater: compareVersions — prerelease never outranks its release ---- + +describe('compareVersions (app-updater version ordering)', () => { + it('orders by numeric components', () => { + expect(compareVersions('0.5.0', '0.4.0')).toBe(1) + expect(compareVersions('0.4.0', '0.5.0')).toBe(-1) + expect(compareVersions('1.2.3', '1.2.3')).toBe(0) + }) + + it('treats a leading v as cosmetic', () => { + expect(compareVersions('v0.5.0', '0.5.0')).toBe(0) + }) + + it('never sorts a prerelease above its final release', () => { + // 0.5.0-rc.1 must not be offered as an "upgrade" over a running 0.5.0 + expect(compareVersions('0.5.0-rc.1', '0.5.0')).toBe(0) + }) +}) + // --- F1: isYtdlpUpdateChannel — --update-to allowlist ----------------------- describe('isYtdlpUpdateChannel (audit F1)', () => {