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) }
}
}