feat(setup): fetch ffmpeg on first run instead of bundling it

Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe
(the bulk of its size). On first run they are downloaded from a pinned
upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and
unpacked with the OS tar.exe into the managed userData/bin dir -- the
same place as the self-updating yt-dlp, so they survive app updates and
portable re-extraction. A hard onboarding gate blocks the app until they
are present, with a "locate existing ffmpeg" folder-picker fallback for
offline machines and a Repair action in Settings > About.

The updater's streaming/checksum/redirect/idle-timeout download loop is
extracted to lib/verifiedDownload.streamVerifiedFile and shared by both
the app-installer download and the ffmpeg fetch; the updater's public
behaviour and error strings are unchanged (its boundary tests still pass).
No new npm dependency: extraction uses the System32 bsdtar resolved by
absolute path (audit F3).

Note: this commit also carries pre-existing, in-progress aria2c/network
and updater-token work that was already uncommitted in the working tree
and is entangled with the above in shared files (updater.ts, ipc.ts,
preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network
files and aria2c.exe). It was not cleanly separable by path, so it is
included here rather than split out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:02:13 -04:00
parent cb25262a2d
commit eb53de2ea5
33 changed files with 1391 additions and 386 deletions
+12 -1
View File
@@ -8,6 +8,7 @@ import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings'
import { useSetup } from './store/setup'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
// Eagerly load the sources store for its startup side-effects (load persisted
@@ -130,6 +131,11 @@ function App(): React.JSX.Element {
useDownloads.getState().hydrate()
}, [])
// Subscribe to ffmpeg setup progress and run the initial readiness check, which drives
// the first-run setup gate below. In an App effect (not at store module load) so it runs
// after window.api is assigned in every context (real bridge and browser-preview mock).
useEffect(() => useSetup.getState().init(), [])
// UX16: focus the URL field on launch so the user can paste + type immediately
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
// register its focuser (L118), matching the command palette's "New download" focus.
@@ -222,7 +228,12 @@ function App(): React.JSX.Element {
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
// Onboarding doubles as the one-time media-tools setup: show it for a fresh user, OR
// whenever ffmpeg/ffprobe are confirmed missing (an existing user upgrading from a
// bundled build). `=== false` (not falsy) so the null "still checking" state never
// flashes the setup screen at a user who actually has ffmpeg.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const showOnboarding = loaded && (!hasCompletedOnboarding || ffmpegReady === false)
return (
<FluentProvider
+49 -3
View File
@@ -2,7 +2,8 @@ import {
DEFAULT_SETTINGS,
type Settings,
type CommandTemplate,
type MediaProfile
type MediaProfile,
type FfmpegSetupProgress
} from '@shared/ipc'
/**
@@ -50,6 +51,11 @@ let mockProfiles: MediaProfile[] = [
{ id: 'p2', name: 'Music (audio only)', kind: 'audio', quality: 'Best' }
]
// First-run ffmpeg setup preview state: ready unless `?setup` is in the preview URL (which
// forces the onboarding setup step), plus the progress callback the fake download drives.
let mockFfmpegReady = typeof location === 'undefined' || !/[?&]setup(\b|=)/.test(location.search)
let mockFfmpegProgressCb: ((p: FfmpegSetupProgress) => void) | null = null
export const mockApi: Api = {
getAppVersion: async () => MOCK_CURRENT_VERSION,
checkForAppUpdate: async () => {
@@ -75,9 +81,46 @@ export const mockApi: Api = {
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
ffmpeg: '8.1.2-essentials_build (UI preview mock)',
ffprobe: '8.1.2-essentials_build (UI preview mock)'
}),
// First-run ffmpeg setup: ready by default so the preview isn't blocked. Append `?setup`
// to the preview URL to force the not-ready state and demo the onboarding setup step —
// the fake download/locate below then flips it ready.
getFfmpegSetupStatus: async () =>
mockFfmpegReady
? {
ready: true,
ffmpeg: '8.1.2-essentials_build (mock)',
ffprobe: '8.1.2-essentials_build (mock)'
}
: { ready: false, ffmpeg: null, ffprobe: null },
downloadFfmpeg: async () => {
for (let i = 1; i <= 10; i++) {
await new Promise((r) => setTimeout(r, 150))
mockFfmpegProgressCb?.({
phase: 'downloading',
received: i * 11_000_000,
total: 110_000_000,
fraction: i / 10
})
}
mockFfmpegProgressCb?.({ phase: 'extracting' })
await new Promise((r) => setTimeout(r, 500))
mockFfmpegReady = true
return { ok: true }
},
cancelFfmpegDownload: async () => {},
locateFfmpeg: async () => {
mockFfmpegReady = true
return { ok: true }
},
onFfmpegSetupProgress: (cb) => {
mockFfmpegProgressCb = cb
return () => {
if (mockFfmpegProgressCb === cb) mockFfmpegProgressCb = null
}
},
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -167,6 +210,9 @@ export const mockApi: Api = {
mockCookiesSavedAt = Date.now()
return { ok: true, cookieCount: 14 }
},
// Preview assumes the bundled aria2c is present, so the Network toggle renders
// in its normal (enabled) state.
aria2cAvailable: async () => true,
cookiesStatus: async () =>
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
cookiesClear: async () => {
+112
View File
@@ -0,0 +1,112 @@
import { create } from 'zustand'
import { logError } from '../reportError'
/**
* First-run setup state: whether the managed ffmpeg/ffprobe are present, and the
* download / locate orchestration for acquiring them. The App gate blocks the app on
* `ffmpegReady === false`, and Onboarding + the About card drive the actions here.
*
* Unlike the settings store, this has no preview-only fallback: `window.api` is the real
* bridge or the mockApi (which simulates a fake download and honours the `?setup` preview
* flag), so the same code path works in both. Init is driven from an App effect — not at
* module load — so it runs after `window.api` is assigned in every context.
*/
interface SetupState {
/** null until the first status check resolves; then true once both binaries are present */
ffmpegReady: boolean | null
ffmpegVersion: string | null
ffprobeVersion: string | null
/** phase of an in-flight acquisition (drives the progress UI) */
phase: 'idle' | 'downloading' | 'extracting'
/** 0..1 download fraction when known, else undefined (indeterminate bar) */
fraction: number | undefined
/** a download or locate is running */
busy: boolean
error: string | null
/** subscribe to progress + run the first status check; returns an unsubscribe fn */
init: () => () => void
/** re-query readiness + versions from main */
check: () => Promise<void>
/** download + unpack ffmpeg from the pinned upstream archive */
download: () => Promise<void>
/** abort the in-flight download */
cancel: () => void
/** pick a folder that already holds ffmpeg + ffprobe and install from it */
locate: () => Promise<void>
}
// Set when the user cancels a download, so the (not-ok) awaited result is treated as a
// cancel rather than surfaced as an error — same idea as the app-update card's canceledRef.
let canceled = false
export const useSetup = create<SetupState>((set, get) => ({
ffmpegReady: null,
ffmpegVersion: null,
ffprobeVersion: null,
phase: 'idle',
fraction: undefined,
busy: false,
error: null,
init: () => {
void get().check()
return (
window.api?.onFfmpegSetupProgress?.((p) =>
set({ phase: p.phase, fraction: p.phase === 'downloading' ? p.fraction : undefined })
) ?? (() => {})
)
},
check: async () => {
try {
const s = await window.api.getFfmpegSetupStatus()
set({ ffmpegReady: s.ready, ffmpegVersion: s.ffmpeg, ffprobeVersion: s.ffprobe })
} catch (e) {
logError('getFfmpegSetupStatus')(e)
}
},
download: async () => {
if (get().busy) return
canceled = false
set({ busy: true, error: null, phase: 'downloading', fraction: undefined })
try {
const r = await window.api.downloadFfmpeg()
// The user hit Cancel: main aborted the download and cleaned up — don't surface its
// not-ok result as an error.
if (canceled) return
if (!r.ok) {
set({ error: r.error ?? 'The ffmpeg download failed.' })
return
}
await get().check() // flips ffmpegReady true, clearing the gate
} catch (e) {
if (!canceled) set({ error: e instanceof Error ? e.message : String(e) })
} finally {
set({ busy: false, phase: 'idle', fraction: undefined })
}
},
cancel: () => {
canceled = true
window.api.cancelFfmpegDownload?.().catch(logError('cancelFfmpegDownload'))
},
locate: async () => {
if (get().busy) return
set({ busy: true, error: null })
try {
const r = await window.api.locateFfmpeg()
if (!r.ok) {
// A dismissed folder picker ('No folder selected.') isn't an error worth showing.
if (r.error && !/no folder selected/i.test(r.error)) set({ error: r.error })
return
}
await get().check()
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e) })
} finally {
set({ busy: false })
}
}
}))
+182 -56
View File
@@ -5,6 +5,8 @@ import {
Caption1,
Field,
Button,
Spinner,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
@@ -12,6 +14,9 @@ import {
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
DismissRegular,
CheckmarkCircleFilled,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
@@ -22,6 +27,8 @@ import {
KeyboardRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { useSetup } from '../store/setup'
import { useErrorTextStyles } from '../components/ui/errorText'
import { SPACE, ICON } from '../components/ui/tokens'
const useStyles = makeStyles({
@@ -99,6 +106,31 @@ const useStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
// Media-components setup box (download/locate ffmpeg on first run).
setupBox: {
display: 'flex',
flexDirection: 'column',
gap: '10px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
setupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
setupButtons: {
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
},
setupReadyIcon: {
fontSize: `${ICON.control}px`,
flexShrink: 0,
color: tokens.colorStatusSuccessForeground1
},
tips: {
display: 'flex',
flexDirection: 'column',
@@ -138,10 +170,88 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const errText = useErrorTextStyles()
const update = useSettings((s) => s.update)
const videoFolder = useSettings((s) => s.videoFolder)
const audioFolder = useSettings((s) => s.audioFolder)
const chooseFolder = useSettings((s) => s.chooseFolder)
// A brand-new user gets the full welcome; an already-onboarded user only lands here
// because ffmpeg is missing (e.g. upgrading from a bundled build) — show a compact
// "finish setup" card that auto-dismisses once the tools are installed.
const isFresh = !useSettings((s) => s.hasCompletedOnboarding)
const ready = useSetup((s) => s.ffmpegReady)
const phase = useSetup((s) => s.phase)
const fraction = useSetup((s) => s.fraction)
const busy = useSetup((s) => s.busy)
const setupError = useSetup((s) => s.error)
const download = useSetup((s) => s.download)
const cancel = useSetup((s) => s.cancel)
const locate = useSetup((s) => s.locate)
const componentsField = (
<Field label="Media components">
<div className={styles.setupBox}>
{ready === true ? (
<div className={styles.setupRow}>
<CheckmarkCircleFilled className={styles.setupReadyIcon} />
<Caption1>ffmpeg and ffprobe are installed and ready.</Caption1>
</div>
) : ready === null ? (
<div className={styles.setupRow}>
<Spinner size="tiny" />
<Caption1>Checking media components</Caption1>
</div>
) : (
<>
<Caption1 className={styles.folderNote}>
AeroFetch uses ffmpeg to merge and convert media. It isn&apos;t bundled download it
once (about 105 MB) and it&apos;s kept across updates. On an offline machine, point
AeroFetch at an ffmpeg you already have.
</Caption1>
{busy ? (
<>
<ProgressBar
value={phase === 'extracting' ? undefined : fraction}
aria-label="ffmpeg setup progress"
/>
<div className={styles.setupRow}>
<Caption1 className={styles.folderNote}>
{phase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'}
</Caption1>
{/* Only the download is cancelable — unpacking runs to completion. */}
{phase === 'downloading' && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancel}
>
Cancel
</Button>
)}
</div>
</>
) : (
<div className={styles.setupButtons}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={() => void download()}
>
Download components
</Button>
<Button icon={<FolderRegular />} onClick={() => void locate()}>
Locate existing ffmpeg
</Button>
</div>
)}
{setupError && <Caption1 className={errText.errorPre}>{setupError}</Caption1>}
</>
)}
</div>
</Field>
)
return (
<div className={styles.root}>
@@ -150,69 +260,85 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2 as="h1">Welcome to AeroFetch</Title2>
<Title2 as="h1">{isFresh ? 'Welcome to AeroFetch' : 'Finish setup'}</Title2>
</div>
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
{isFresh ? (
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp is bundled; ffmpeg is set up once below.
</Body1>
) : (
<Body1>
AeroFetch needs ffmpeg and ffprobe to process downloads. They aren&apos;t bundled set
them up once to continue.
</Body1>
)}
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos and audio save to your Documents folders by default. Pick a different folder now,
or change it any time in Settings.
</Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
{videoFolder || 'Documents\\Video'}
</Caption1>
{isFresh && (
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos and audio save to your Documents folders by default. Pick a different folder
now, or change it any time in Settings.
</Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
{videoFolder || 'Documents\\Video'}
</Caption1>
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('videoFolder')}
>
Choose
</Button>
</div>
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioFolder || 'Documents\\Audio'}>
{audioFolder || 'Documents\\Audio'}
</Caption1>
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('audioFolder')}
>
Choose
</Button>
</div>
</Field>
)}
{componentsField}
{isFresh && (
<>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('videoFolder')}
appearance="primary"
icon={<RocketRegular />}
disabled={ready !== true}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Choose
Get started
</Button>
</div>
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioFolder || 'Documents\\Audio'}>
{audioFolder || 'Documents\\Audio'}
</Caption1>
</div>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('audioFolder')}
>
Choose
</Button>
</div>
</Field>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
appearance="primary"
icon={<RocketRegular />}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Get started
</Button>
</>
)}
</Card>
</div>
)
+65 -3
View File
@@ -7,12 +7,20 @@ import {
Caption1,
Text,
Spinner,
ProgressBar,
Skeleton,
SkeletonItem
} from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import {
InfoRegular,
ArrowSyncRegular,
ArrowDownloadRegular,
DismissRegular,
FolderRegular
} from '@fluentui/react-icons'
import { type YtdlpUpdateChannel } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useSetup } from '../../store/setup'
import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
@@ -43,6 +51,25 @@ export function AboutCard(): React.JSX.Element {
refreshFfmpeg
} = useAboutCard()
// ffmpeg is fetched on first run (not bundled); the setup store owns its download/locate
// orchestration + progress. Re-read the displayed versions once an action settles.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const setupBusy = useSetup((s) => s.busy)
const setupPhase = useSetup((s) => s.phase)
const setupFraction = useSetup((s) => s.fraction)
const setupError = useSetup((s) => s.error)
const downloadFfmpeg = useSetup((s) => s.download)
const cancelFfmpeg = useSetup((s) => s.cancel)
const locateFfmpeg = useSetup((s) => s.locate)
async function getFfmpeg(): Promise<void> {
await downloadFfmpeg()
refreshFfmpeg()
}
async function locate(): Promise<void> {
await locateFfmpeg()
refreshFfmpeg()
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
@@ -50,8 +77,8 @@ export function AboutCard(): React.JSX.Element {
<Subtitle2 as="h3">About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
AeroFetch is a generic frontend for yt-dlp. It manages its own copy of yt-dlp (keeping it up
to date automatically) and downloads ffmpeg on first run.
</Caption1>
<div className={styles.folderRow}>
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
@@ -98,6 +125,41 @@ export function AboutCard(): React.JSX.Element {
/>
</div>
)}
{ffmpeg &&
(setupBusy ? (
<>
<ProgressBar
value={setupPhase === 'extracting' ? undefined : setupFraction}
aria-label="ffmpeg setup progress"
/>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{setupPhase === 'extracting' ? 'Unpacking…' : 'Downloading ffmpeg…'}
</Caption1>
{/* Only the download is cancelable — unpacking runs to completion. */}
{setupPhase === 'downloading' && (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={cancelFfmpeg}
>
Cancel
</Button>
)}
</div>
</>
) : (
<div className={styles.folderRow}>
<Button icon={<ArrowDownloadRegular />} onClick={() => void getFfmpeg()}>
{ffmpegReady === true ? 'Repair ffmpeg' : 'Download ffmpeg'}
</Button>
<Button icon={<FolderRegular />} onClick={() => void locate()}>
Locate existing
</Button>
</div>
))}
{setupError && <Caption1 className={errText.errorPre}>{setupError}</Caption1>}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
@@ -26,8 +26,12 @@ export function NetworkCard(): React.JSX.Element {
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update)
// View-model hook owns the PO-token minting state + IPC (L93/CC13).
const { mintingPot, potHint, clearPotHint, mintPoToken } = useNetworkCard()
// View-model hook owns the PO-token minting state + IPC (L93/CC13), plus the
// aria2c-presence probe that keeps the toggle below honest.
const { mintingPot, potHint, clearPotHint, mintPoToken, aria2cAvailable } = useNetworkCard()
// Only treat aria2c as missing once the probe has actually resolved false, so a
// slow check doesn't briefly disable a working toggle.
const aria2cMissing = aria2cAvailable === false
return (
<Card className={styles.card}>
@@ -114,11 +118,21 @@ export function NetworkCard(): React.JSX.Element {
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
validationState={aria2cMissing ? 'warning' : 'none'}
validationMessage={
aria2cMissing
? "aria2c isn't included in this build, so this setting has no effect."
: undefined
}
>
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
<Switch
checked={useAria2c}
disabled={aria2cMissing}
onChange={(_, d) => update({ useAria2c: d.checked })}
/>
</Field>
{useAria2c && (
{useAria2c && !aria2cMissing && (
<Field
label="aria2c connections"
hint="Parallel connections per download (116). More can be faster on throttled connections; fewer is gentler on servers."
@@ -1,6 +1,4 @@
import {
Field,
Input,
Button,
Card,
Subtitle2,
@@ -16,7 +14,6 @@ import {
OpenRegular,
DismissRegular
} from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
@@ -24,8 +21,6 @@ import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.update)
// View-model hook owns the check → download → install orchestration (L93/CC13).
const {
@@ -63,21 +58,6 @@ export function SoftwareUpdateCard(): React.JSX.Element {
</Button>
</div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field
label="Update access token"
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
)}
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
@@ -38,7 +38,7 @@ export function useAboutCard(): AboutCardController {
.then(setVersion)
.catch(logError('getYtdlpVersion'))
.finally(() => setChecking(false))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
// ffmpeg/ffprobe are display-only (fetched on first run, not auto-updated); load them once.
// On a bridge failure, settle to "not found" rather than a permanent skeleton.
window.api
.getFfmpegVersions()
@@ -1,19 +1,33 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { logError } from '../../reportError'
export interface NetworkCardController {
mintingPot: boolean
potHint: string | null
clearPotHint: () => void
mintPoToken: () => Promise<void>
/** whether aria2c.exe shipped with this build; null while the check is in flight */
aria2cAvailable: boolean | null
}
/**
* View-model for the Network card's PO-token minting (L93/CC13): owns the fetch
* state and hint copy so the card stays presentational.
* state and hint copy so the card stays presentational. Also probes whether the
* optional aria2c downloader is bundled, so the card can disable its toggle rather
* than leave a setting that silently does nothing.
*/
export function useNetworkCard(): NetworkCardController {
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
const [aria2cAvailable, setAria2cAvailable] = useState<boolean | null>(null)
useEffect(() => {
// Detect whether the optional aria2c downloader is bundled so the toggle can
// disable itself instead of silently doing nothing. A failed probe leaves the
// state null (unknown), which keeps the toggle enabled rather than wrongly
// disabling a working feature.
window.api.aria2cAvailable().then(setAria2cAvailable).catch(logError('aria2cAvailable'))
}, [])
async function mintPoToken(): Promise<void> {
setMintingPot(true)
@@ -32,5 +46,5 @@ export function useNetworkCard(): NetworkCardController {
}
}
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken }
return { mintingPot, potHint, clearPotHint: () => setPotHint(null), mintPoToken, aria2cAvailable }
}