From 981d2dd34dc45c1ccf9879047e1876e632dfe156 Mon Sep 17 00:00:00 2001 From: Wayne Date: Wed, 24 Jun 2026 22:38:35 -0400 Subject: [PATCH] Add in-app updater: check Gitea releases, show changelog, download + run installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Settings → "Software update" card lets users update AeroFetch from inside the app: - src/main/updater.ts — queries the configured Gitea repo's latest release over the public REST API, compares the tag to app.getVersion(), and (on request) streams the installer asset to the OS temp dir with progress events, then launches it and quits so it can replace the app. - Security: only the trusted update host over HTTPS is ever downloaded from, and only a freshly-downloaded .exe inside our temp dir is ever executed — the download URL from the release JSON can't redirect us elsewhere. No token is ever shipped; the source repo must be anonymously readable (public). - IPC: app:update-check / -download / -run + an -progress push channel, exposed via preload (checkForAppUpdate / downloadAppUpdate / runAppUpdate / onAppUpdateProgress). - UI: "Check for updates" → shows the new version and its release notes, then "Update now" (with a download progress bar) or "View release". Up-to-date and error states handled. Preview mock added so the flow is exercisable in `npm run ui`. Note: the update source repo/host are constants at the top of updater.ts (currently debont80/AeroFetch). Downloads work once the release repo is public and the Gitea instance allows anonymous API + asset access. Co-Authored-By: Claude Opus 4.8 --- src/main/index.ts | 7 + src/main/updater.ts | 182 +++++++++++++++++++ src/preload/index.ts | 21 +++ src/renderer/src/components/SettingsView.tsx | 132 +++++++++++++- src/renderer/src/main.tsx | 22 ++- src/shared/ipc.ts | 47 +++++ 6 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 src/main/updater.ts 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..63e9e8b --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,182 @@ +import { app, shell, type WebContents } from 'electron' +import { createWriteStream } from 'fs' +import { stat } from 'fs/promises' +import { join } from 'path' +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, even though downloadUrl comes from the release JSON. A +// tampered/man-in-the-middled response can't redirect us to an arbitrary host. +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 a version like 'v1.2.3' / '1.2.3' into comparable numeric components. */ +function parseVersion(v: string): number[] { + return v + .replace(/^v/i, '') + .split(/[.\-+]/) + .map((p) => parseInt(p, 10)) + .filter((n) => !Number.isNaN(n)) +} + +/** Component-wise numeric compare: -1 if ab. */ +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[] +} + +/** 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() + try { + const res = await fetch(RELEASE_API, { headers: { Accept: 'application/json' } }) + 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, + htmlUrl: rel.html_url, + downloadUrl: asset?.browser_download_url, + assetName: asset?.name + } + } catch (e) { + return { + ok: false, + available: false, + currentVersion, + error: e instanceof Error ? e.message : String(e) + } + } +} + +/** 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.' } + } + try { + const res = await fetch(url) + if (!res.ok || !res.body) return { ok: false, error: `Download failed (HTTP ${res.status}).` } + + const total = Number(res.headers.get('content-length')) || undefined + // 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) + + const fileStream = createWriteStream(filePath) + let received = 0 + const reader = res.body.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + received += value.length + // Respect backpressure so a ~300 MB installer doesn't buffer in memory. + if (!fileStream.write(Buffer.from(value))) { + await new Promise((resolve) => fileStream.once('drain', resolve)) + } + const progress: AppUpdateProgress = { + received, + total, + fraction: total ? received / total : undefined + } + if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress) + } + await new Promise((resolve, reject) => + fileStream.end((err?: Error | null) => (err ? reject(err) : resolve())) + ) + return { ok: true, filePath } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } +} + +/** 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 we just wrote into our own temp dir, never + // an arbitrary path handed over IPC. + const dir = updateDir().toLowerCase() + if (!filePath.toLowerCase().startsWith(dir) || !/\.exe$/i.test(filePath)) { + return { ok: false, error: 'Refused to run an unexpected file.' } + } + await stat(filePath) // throws if the file is missing + const err = await shell.openPath(filePath) // 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(), 1500) + 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..b315262 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,45 @@ 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 { + setAppUpd(await window.api.checkForAppUpdate()) + } 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 +827,74 @@ 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. + )} + + {appUpd && !appUpd.ok && ( + + {appUpd.error} + + )} + {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 `). *