Merge pull request 'In-app updater (check releases, show changelog, download + install)' (#4) from feat/app-updater into main
Reviewed-on: #4
This commit was merged in pull request #4.
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,394 @@
|
||||
import { app, net, shell, type WebContents } from 'electron'
|
||||
import { createWriteStream, type WriteStream } from 'fs'
|
||||
import { stat, unlink } from 'fs/promises'
|
||||
import { join, normalize, dirname } from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
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. downloadUrl comes from the release JSON, and Gitea 302-redirects
|
||||
// release downloads to its attachment store — so this is re-checked on EVERY
|
||||
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
|
||||
// response from bouncing us off the trusted host.
|
||||
export 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 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
|
||||
* release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
|
||||
* the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
|
||||
* release (0.5.0): at most it compares equal, so we won't offer a prerelease as
|
||||
* an "upgrade" over the same shipped version.
|
||||
*/
|
||||
function parseVersion(v: string): number[] {
|
||||
return v
|
||||
.replace(/^v/i, '')
|
||||
.split(/[-+]/)[0]
|
||||
.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. */
|
||||
export 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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
|
||||
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||
* longer run like a sha512 digest is ignored rather than sliced).
|
||||
*/
|
||||
export function extractSha256(text: string): string | null {
|
||||
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
||||
return m ? m[0].toLowerCase() : null
|
||||
}
|
||||
|
||||
/** 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()
|
||||
// Bound the check so a stalled/unreachable server can't hang the UI spinner
|
||||
// indefinitely — mirrors the timeouts on the yt-dlp calls.
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||
try {
|
||||
const res = await fetch(RELEASE_API, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
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,
|
||||
// Only ever hand the OS browser a release page on our own trusted host.
|
||||
htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
|
||||
downloadUrl: asset?.browser_download_url,
|
||||
assetName: asset?.name
|
||||
}
|
||||
} catch (e) {
|
||||
const timedOut = e instanceof Error && e.name === 'AbortError'
|
||||
return {
|
||||
ok: false,
|
||||
available: false,
|
||||
currentVersion,
|
||||
error: timedOut
|
||||
? 'Update check timed out — the server took too long to respond.'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the download may stall (no bytes received) before we give up. */
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
|
||||
|
||||
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
|
||||
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
|
||||
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
|
||||
// to run an installer we can't verify against it. Note this is INTEGRITY +
|
||||
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
|
||||
// bounced to other storage), NOT protection against a fully compromised host —
|
||||
// that host serves both files, so only Authenticode signing defends against it.
|
||||
const REQUIRE_CHECKSUM: boolean = true
|
||||
|
||||
/**
|
||||
* GET a small text resource from the trusted host, re-validating the host on
|
||||
* every redirect hop (same discipline as the installer download) and capping the
|
||||
* body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
|
||||
*/
|
||||
function fetchTrustedText(
|
||||
url: string,
|
||||
maxBytes = 64 * 1024,
|
||||
timeoutMs = 15_000
|
||||
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (!r.ok) request.abort()
|
||||
resolve(r)
|
||||
}
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
||||
request.on('redirect', (_s, _m, redirectUrl) => {
|
||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||
done({ ok: false, error: 'redirect to an untrusted location' })
|
||||
return
|
||||
}
|
||||
request.followRedirect()
|
||||
})
|
||||
request.on('response', (response) => {
|
||||
const status = response.statusCode
|
||||
if (status < 200 || status >= 300) {
|
||||
done({ ok: false, status, error: `HTTP ${status}` })
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
response.on('data', (c: Buffer) => {
|
||||
size += c.length
|
||||
if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
|
||||
else chunks.push(c)
|
||||
})
|
||||
response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
|
||||
response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
|
||||
response.on('error', (e: Error) => done({ ok: false, error: e.message }))
|
||||
})
|
||||
request.on('error', (e: Error) => done({ ok: false, error: e.message }))
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** 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.' }
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Resolve the published checksum up front (tiny, fails fast) so we never pull a
|
||||
// ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
|
||||
// so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
|
||||
const checksumUrl = (() => {
|
||||
const u = new URL(url)
|
||||
u.pathname += '.sha256'
|
||||
return u.toString()
|
||||
})()
|
||||
const sum = await fetchTrustedText(checksumUrl)
|
||||
let expectedSha: string | null = null
|
||||
if (sum.ok) {
|
||||
expectedSha = extractSha256(sum.text)
|
||||
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||
} else if (sum.status === 404) {
|
||||
if (REQUIRE_CHECKSUM) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
|
||||
}
|
||||
|
||||
return new Promise<AppUpdateDownload>((resolve) => {
|
||||
let settled = false
|
||||
let fileStream: WriteStream | null = null
|
||||
let idle: NodeJS.Timeout | null = null
|
||||
|
||||
// Single teardown point. On failure it also aborts the request, so a write
|
||||
// error (disk full, etc.) can't leave Electron pulling bytes into a dead
|
||||
// stream. abort() is idempotent, so the call sites below don't repeat it.
|
||||
const finish = (result: AppUpdateDownload): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (idle) clearTimeout(idle)
|
||||
if (!result.ok) {
|
||||
request.abort()
|
||||
// Close the handle before unlinking (Windows won't delete an open file)
|
||||
// and never leave a partial/aborted installer lying around in temp.
|
||||
if (fileStream && !fileStream.destroyed) fileStream.destroy()
|
||||
unlink(filePath).catch(() => {})
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
// net.request (not fetch) so we can re-validate the host on EVERY redirect
|
||||
// hop: Gitea 302s a release download to its attachment store, and undici's
|
||||
// fetch hides the Location header under redirect:'manual', so it can't gate
|
||||
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
|
||||
const armIdle = (): void => {
|
||||
if (idle) clearTimeout(idle)
|
||||
idle = setTimeout(() => {
|
||||
finish({ ok: false, error: 'Download stalled — please try again.' })
|
||||
}, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
request.on('redirect', (_status, _method, redirectUrl) => {
|
||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
|
||||
return
|
||||
}
|
||||
request.followRedirect()
|
||||
})
|
||||
|
||||
request.on('response', (response) => {
|
||||
const status = response.statusCode
|
||||
if (status < 200 || status >= 300) {
|
||||
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
|
||||
return
|
||||
}
|
||||
|
||||
const lenHeader = response.headers['content-length']
|
||||
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
|
||||
|
||||
const stream = createWriteStream(filePath)
|
||||
fileStream = stream
|
||||
// Hash the bytes as they stream by, so verification needs no second pass.
|
||||
const hash = createHash('sha256')
|
||||
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume()
|
||||
// exist at runtime but aren't in its type, so feature-detect them to apply
|
||||
// backpressure — without it a ~300 MB installer can buffer in memory.
|
||||
const flow = response as unknown as { pause?(): void; resume?(): void }
|
||||
let received = 0
|
||||
armIdle()
|
||||
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
armIdle()
|
||||
received += chunk.length
|
||||
hash.update(chunk)
|
||||
if (!stream.write(chunk) && flow.pause && flow.resume) {
|
||||
flow.pause()
|
||||
stream.once('drain', () => flow.resume?.())
|
||||
}
|
||||
if (!wc.isDestroyed()) {
|
||||
const progress: AppUpdateProgress = {
|
||||
received,
|
||||
total,
|
||||
fraction: total ? received / total : undefined
|
||||
}
|
||||
wc.send(IpcChannels.appUpdateProgress, progress)
|
||||
}
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
// Body fully received — "stalled" is no longer meaningful past this point.
|
||||
if (idle) clearTimeout(idle)
|
||||
stream.end(() => {
|
||||
// A truncated download (connection dropped mid-stream) must never be run.
|
||||
if (total !== undefined && received !== total) {
|
||||
finish({ ok: false, error: 'The download was incomplete — please try again.' })
|
||||
return
|
||||
}
|
||||
// Verify the bytes we wrote match the release's published SHA-256.
|
||||
if (expectedSha && hash.digest('hex') !== expectedSha) {
|
||||
finish({
|
||||
ok: false,
|
||||
error:
|
||||
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({ ok: true, filePath })
|
||||
})
|
||||
})
|
||||
|
||||
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
|
||||
// promise can't hang.
|
||||
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
|
||||
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
})
|
||||
|
||||
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Let the launched installer spawn before we quit to release our files (ms). */
|
||||
const INSTALLER_HANDOFF_MS = 1500
|
||||
|
||||
/** 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 that sits DIRECTLY in our own temp dir,
|
||||
// never an arbitrary path handed over IPC. Compare the parent directory by
|
||||
// equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
|
||||
const expectedDir = normalize(updateDir()).toLowerCase()
|
||||
const target = normalize(filePath)
|
||||
if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
|
||||
return { ok: false, error: 'Refused to run an unexpected file.' }
|
||||
}
|
||||
await stat(target) // throws if the file is missing
|
||||
const err = await shell.openPath(target) // 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(), INSTALLER_HANDOFF_MS)
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,49 @@ 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 {
|
||||
const info = await window.api.checkForAppUpdate()
|
||||
// Funnel a failed check into the single error slot rather than storing a
|
||||
// second not-ok AppUpdateInfo, so only one error message can ever render.
|
||||
if (info.ok) setAppUpd(info)
|
||||
else setAppUpdError(info.error ?? 'Update check failed.')
|
||||
} 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 +831,69 @@ 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'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're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
||||
)}
|
||||
|
||||
{appUpdError && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
|
||||
{appUpdError}
|
||||
</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<InfoRegular className={styles.sectionIcon} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`).
|
||||
*
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { assertHttpUrl } from '../src/main/url'
|
||||
import { isAllowedLoginUrl } from '../src/main/cookies'
|
||||
import { isTrustedDownloadUrl, extractSha256, compareVersions } from '../src/main/updater'
|
||||
import { isYtdlpUpdateChannel } from '@shared/ipc'
|
||||
|
||||
// --- F5: assertHttpUrl — argument-injection guard + normalisation -----------
|
||||
@@ -68,6 +69,88 @@ describe('isAllowedLoginUrl (audit T4)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops -------
|
||||
|
||||
describe('isTrustedDownloadUrl (app-updater host pin)', () => {
|
||||
const onHost =
|
||||
'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
|
||||
|
||||
it('accepts https URLs on the exact update host (incl. port)', () => {
|
||||
expect(isTrustedDownloadUrl(onHost)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a different host — the redirect/MITM bounce vector', () => {
|
||||
expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects the right hostname on the wrong port', () => {
|
||||
// host comparison includes the port, so :443 (default) is not the same origin
|
||||
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects plain http even on the update host', () => {
|
||||
expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false)
|
||||
})
|
||||
|
||||
it('is not fooled by the trusted host placed in the userinfo', () => {
|
||||
expect(
|
||||
isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unparseable input', () => {
|
||||
expect(isTrustedDownloadUrl('not a url')).toBe(false)
|
||||
expect(isTrustedDownloadUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- app-updater: extractSha256 — checksum-file parsing ----------------------
|
||||
|
||||
describe('extractSha256 (app-updater checksum parsing)', () => {
|
||||
const hash = 'a'.repeat(64)
|
||||
|
||||
it('reads a bare lowercase digest', () => {
|
||||
expect(extractSha256(hash)).toBe(hash)
|
||||
})
|
||||
|
||||
it('reads sha256sum format (`<hash> filename`)', () => {
|
||||
expect(extractSha256(`${hash} AeroFetch-Setup-0.5.0.exe\n`)).toBe(hash)
|
||||
})
|
||||
|
||||
it('lowercases a PowerShell Get-FileHash (uppercase) digest', () => {
|
||||
expect(extractSha256('A'.repeat(64))).toBe(hash)
|
||||
})
|
||||
|
||||
it('returns null when there is no standalone 64-char hex token', () => {
|
||||
expect(extractSha256('')).toBeNull()
|
||||
expect(extractSha256('not a checksum')).toBeNull()
|
||||
expect(extractSha256('deadbeef')).toBeNull() // too short
|
||||
})
|
||||
|
||||
it('does not slice a 64-char window out of a longer hex run (e.g. sha512)', () => {
|
||||
expect(extractSha256('a'.repeat(128))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --- app-updater: compareVersions — prerelease never outranks its release ----
|
||||
|
||||
describe('compareVersions (app-updater version ordering)', () => {
|
||||
it('orders by numeric components', () => {
|
||||
expect(compareVersions('0.5.0', '0.4.0')).toBe(1)
|
||||
expect(compareVersions('0.4.0', '0.5.0')).toBe(-1)
|
||||
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a leading v as cosmetic', () => {
|
||||
expect(compareVersions('v0.5.0', '0.5.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('never sorts a prerelease above its final release', () => {
|
||||
// 0.5.0-rc.1 must not be offered as an "upgrade" over a running 0.5.0
|
||||
expect(compareVersions('0.5.0-rc.1', '0.5.0')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- F1: isYtdlpUpdateChannel — --update-to allowlist -----------------------
|
||||
|
||||
describe('isYtdlpUpdateChannel (audit F1)', () => {
|
||||
|
||||
Reference in New Issue
Block a user