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 `). *