Add in-app updater: check Gitea releases, show changelog, download + run installer

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 22:38:35 -04:00
parent 08b9ed9e7a
commit 981d2dd34d
6 changed files with 409 additions and 2 deletions
+7
View File
@@ -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))
+182
View File
@@ -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 a<b, 0 if equal, 1 if a>b. */
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<AppUpdateInfo> {
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<AppUpdateDownload> {
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<void>((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<void>((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) }
}
}
+21
View File
@@ -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<string> => ipcRenderer.invoke(IpcChannels.appVersion),
/** Check the configured Gitea repo for a newer AeroFetch release. */
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
/** Download the latest release's installer to a temp file. */
downloadAppUpdate: (url: string): Promise<AppUpdateDownload> =>
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<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
+131 -1
View File
@@ -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<YtdlpUpdateResult | null>(null)
// --- AeroFetch app self-update ---
const [appVersion, setAppVersion] = useState('')
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
const [appChecking, setAppChecking] = useState(false)
const [appDownloading, setAppDownloading] = useState(false)
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
const [appUpdError, setAppUpdError] = useState<string | null>(null)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(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<void> {
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<void> {
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<void> {
setExporting(true)
setExportResult(null)
@@ -765,6 +827,74 @@ export function SettingsView(): React.JSX.Element {
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2>Software update</Subtitle2>
</div>
<Caption1 className={styles.hint}>
{appVersion
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
: 'Checking your AeroFetch version…'}
</Caption1>
<div className={styles.folderRow}>
<Button
icon={<ArrowSyncRegular />}
onClick={checkAppUpdate}
disabled={appChecking || appDownloading}
>
{appChecking ? 'Checking…' : 'Check for updates'}
</Button>
{appChecking && <Spinner size="tiny" />}
</div>
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
Version {appUpd.latestVersion} is available here&apos;s what changed:
</Text>
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
<div className={styles.folderRow}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={installAppUpdate}
disabled={appDownloading || !appUpd.downloadUrl}
>
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
View release
</Button>
)}
</div>
{!appUpd.downloadUrl && (
<Caption1 className={styles.hint}>
This release has no installer attached use View release to download it manually.
</Caption1>
)}
{appDownloading && <ProgressBar value={appFraction} />}
</>
)}
{appUpd?.ok && !appUpd.available && (
<Text>You&apos;re up to date v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpd && !appUpd.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{appUpd.error}
</Caption1>
)}
{appUpdError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{appUpdError}
</Caption1>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
+21 -1
View File
@@ -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:
'### Whats 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
+47
View File
@@ -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 <channel>`).
*