In-app updater (check releases, show changelog, download + install) #4
@@ -11,6 +11,7 @@ import {
|
|||||||
type SystemThemeInfo
|
type SystemThemeInfo
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
import { startDownload, cancelDownload, previewCommand } from './download'
|
||||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||||
@@ -181,6 +182,12 @@ function createWindow(): void {
|
|||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
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.ytdlpVersion, () => getYtdlpVersion())
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
|
type AppUpdateInfo,
|
||||||
|
type AppUpdateDownload,
|
||||||
|
type AppUpdateProgress,
|
||||||
type YtdlpVersionResult,
|
type YtdlpVersionResult,
|
||||||
type YtdlpUpdateChannel,
|
type YtdlpUpdateChannel,
|
||||||
type YtdlpUpdateResult,
|
type YtdlpUpdateResult,
|
||||||
@@ -31,6 +34,24 @@ const api = {
|
|||||||
/** AeroFetch's own version string (e.g. '0.3.1'). */
|
/** AeroFetch's own version string (e.g. '0.3.1'). */
|
||||||
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
|
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> =>
|
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Caption1,
|
Caption1,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
|
ProgressBar,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
mergeClasses,
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -31,13 +32,16 @@ import {
|
|||||||
CopyRegular,
|
CopyRegular,
|
||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
PaintBucketRegular,
|
PaintBucketRegular,
|
||||||
AccessibilityRegular
|
AccessibilityRegular,
|
||||||
|
ArrowSyncRegular,
|
||||||
|
OpenRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import {
|
import {
|
||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
type YtdlpVersionResult,
|
type YtdlpVersionResult,
|
||||||
type YtdlpUpdateChannel,
|
type YtdlpUpdateChannel,
|
||||||
type YtdlpUpdateResult,
|
type YtdlpUpdateResult,
|
||||||
|
type AppUpdateInfo,
|
||||||
type MediaKind,
|
type MediaKind,
|
||||||
type CookieSource,
|
type CookieSource,
|
||||||
type CookieBrowser,
|
type CookieBrowser,
|
||||||
@@ -71,6 +75,17 @@ const useStyles = makeStyles({
|
|||||||
padding: '20px',
|
padding: '20px',
|
||||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
...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: {
|
sectionHeader: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -209,6 +224,14 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
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 [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
||||||
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
||||||
const [signingIn, setSigningIn] = useState(false)
|
const [signingIn, setSigningIn] = useState(false)
|
||||||
@@ -223,6 +246,45 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
window.api.cookiesStatus().then(setCookiesStatus)
|
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> {
|
async function exportBackup(): Promise<void> {
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
setExportResult(null)
|
setExportResult(null)
|
||||||
@@ -765,6 +827,74 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<InfoRegular className={styles.sectionIcon} />
|
<InfoRegular className={styles.sectionIcon} />
|
||||||
|
|||||||
@@ -45,7 +45,27 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
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)' }),
|
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||||
probe: async (url: string) => {
|
probe: async (url: string) => {
|
||||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
export const IpcChannels = {
|
export const IpcChannels = {
|
||||||
/** the AeroFetch app version (package.json / app.getVersion) */
|
/** the AeroFetch app version (package.json / app.getVersion) */
|
||||||
appVersion: 'app:version',
|
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',
|
ytdlpVersion: 'ytdlp:version',
|
||||||
probe: 'media:probe',
|
probe: 'media:probe',
|
||||||
downloadStart: 'download:start',
|
downloadStart: 'download:start',
|
||||||
@@ -188,6 +196,45 @@ export interface YtdlpVersionResult {
|
|||||||
error?: string
|
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>`).
|
* yt-dlp's self-update release channels (`--update-to <channel>`).
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user