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:
@@ -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))
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user