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:
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -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't bundled — download it
|
||||
once (about 105 MB) and it'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'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'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>
|
||||
)
|
||||
|
||||
@@ -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 (1–16). 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user