feat(audit): Batch 23 — settings key renames with migration (CC1) + config.ts (CC11)

CC1: customCommandEnabled→enableCustomCommands, downloadArchive→
useDownloadArchive, clipboardWatch→watchClipboard, sidebarCollapsed→
isSidebarCollapsed, hardwareAcceleration→useHardwareAcceleration,
videoDir/audioDir→videoFolder/audioFolder (+ chooseDir/clearDir store
actions → chooseFolder/clearFolder). Rename map in settingsMigration.ts
(pure, unit-tested incl. pre-rename backup fixture), applied as an
idempotent on-disk shim at store open and on backup import so old
settings.json and old backups both keep working.

CC11: update host/owner/repo + release API moved to src/main/config.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:34:05 -04:00
parent 545cfeed5e
commit 134c6a167c
25 changed files with 288 additions and 135 deletions
+3 -3
View File
@@ -69,7 +69,7 @@ function App(): React.JSX.Element {
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const showTerminal = useSettings((s) => s.customCommandEnabled)
const showTerminal = useSettings((s) => s.enableCustomCommands)
const isDark = useResolvedDark()
const tab = useNav((s) => s.tab)
const setTab = useNav((s) => s.setTab)
@@ -214,9 +214,9 @@ function App(): React.JSX.Element {
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
}, [])
const collapsed = useSettings((s) => s.sidebarCollapsed)
const collapsed = useSettings((s) => s.isSidebarCollapsed)
function toggleCollapsed(): void {
updateSettings({ sidebarCollapsed: !collapsed })
updateSettings({ isSidebarCollapsed: !collapsed })
}
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
+17 -9
View File
@@ -139,9 +139,9 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const update = useSettings((s) => s.update)
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
const videoFolder = useSettings((s) => s.videoFolder)
const audioFolder = useSettings((s) => s.audioFolder)
const chooseFolder = useSettings((s) => s.chooseFolder)
return (
<div className={styles.root}>
@@ -167,11 +167,15 @@ export function Onboarding(): React.JSX.Element {
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
{videoDir || 'Documents\\Video'}
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
{videoFolder || 'Documents\\Video'}
</Caption1>
</div>
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('videoFolder')}
>
Choose
</Button>
</div>
@@ -179,11 +183,15 @@ export function Onboarding(): React.JSX.Element {
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
{audioDir || 'Documents\\Audio'}
<Caption1 className={styles.folderPath} title={audioFolder || 'Documents\\Audio'}>
{audioFolder || 'Documents\\Audio'}
</Caption1>
</div>
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
<Button
size="small"
icon={<FolderRegular />}
onClick={() => chooseFolder('audioFolder')}
>
Choose
</Button>
</div>
+6 -6
View File
@@ -61,13 +61,13 @@ const useStyles = makeStyles({
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* binary, and watch its output stream. Gated on the enableCustomCommands consent
* flag (enforced again in main -- see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const enableCustomCommands = useSettings((s) => s.enableCustomCommands)
const updateSettings = useSettings((s) => s.update)
// View-model hook owns the run/stream/stop orchestration + IPC (L93/CC13).
@@ -101,7 +101,7 @@ export function TerminalView(): React.JSX.Element {
}
/>
{!customCommandEnabled && (
{!enableCustomCommands && (
<div className={styles.gate}>
<Body1>
The terminal runs the bundled yt-dlp with your own arguments part of custom
@@ -110,7 +110,7 @@ export function TerminalView(): React.JSX.Element {
<Button
appearance="primary"
size="small"
onClick={() => updateSettings({ customCommandEnabled: true })}
onClick={() => updateSettings({ enableCustomCommands: true })}
>
Enable custom commands
</Button>
@@ -132,7 +132,7 @@ export function TerminalView(): React.JSX.Element {
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
disabled={!enableCustomCommands}
/>
</div>
<div className={styles.buttons}>
@@ -145,7 +145,7 @@ export function TerminalView(): React.JSX.Element {
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
disabled={!enableCustomCommands || !args.trim()}
>
Run
</Button>
@@ -28,7 +28,7 @@ export function AppearanceCard(): React.JSX.Element {
const focus = useFocusStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const hardwareAcceleration = useSettings((s) => s.hardwareAcceleration)
const useHardwareAcceleration = useSettings((s) => s.useHardwareAcceleration)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const update = useSettings((s) => s.update)
// Store action wraps the IPC call (L93/CC13: no window.api in components).
@@ -105,8 +105,8 @@ export function AppearanceCard(): React.JSX.Element {
hint="Uses the GPU to draw the window (takes effect after you restart AeroFetch). Off by default: some older GPUs render the window blank with this on — turn it back off if that happens."
>
<Switch
checked={hardwareAcceleration}
onChange={(_, d) => update({ hardwareAcceleration: d.checked })}
checked={useHardwareAcceleration}
onChange={(_, d) => update({ useHardwareAcceleration: d.checked })}
/>
</Field>
@@ -8,7 +8,7 @@ import { useSettingsStyles } from './settingsStyles'
export function CustomCommandsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const enableCustomCommands = useSettings((s) => s.enableCustomCommands)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
@@ -30,12 +30,12 @@ export function CustomCommandsCard(): React.JSX.Element {
hint="Applies the default template below to every new download (still overridable per download)."
>
<Switch
checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
checked={enableCustomCommands}
onChange={(_, d) => update({ enableCustomCommands: d.checked })}
/>
</Field>
{customCommandEnabled && (
{enableCustomCommands && (
<Field label="Default template">
<Select
value={defaultTemplateId ?? 'none'}
@@ -18,15 +18,15 @@ import { useSettingsStyles } from './settingsStyles'
export function DownloadsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
const clearDir = useSettings((s) => s.clearDir)
const videoFolder = useSettings((s) => s.videoFolder)
const audioFolder = useSettings((s) => s.audioFolder)
const chooseFolder = useSettings((s) => s.chooseFolder)
const clearFolder = useSettings((s) => s.clearFolder)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const watchClipboard = useSettings((s) => s.watchClipboard)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const launchAtStartup = useSettings((s) => s.launchAtStartup)
@@ -58,16 +58,16 @@ export function DownloadsCard(): React.JSX.Element {
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={videoDir}
value={videoFolder}
placeholder="Documents\Video (default)"
contentBefore={<FolderRegular />}
onChange={(_, d) => update({ videoDir: d.value })}
onChange={(_, d) => update({ videoFolder: d.value })}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
<Button icon={<FolderRegular />} onClick={() => chooseFolder('videoFolder')}>
Browse
</Button>
{videoDir && (
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
{videoFolder && (
<Button appearance="subtle" onClick={() => clearFolder('videoFolder')}>
Reset
</Button>
)}
@@ -81,16 +81,16 @@ export function DownloadsCard(): React.JSX.Element {
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={audioDir}
value={audioFolder}
placeholder="Documents\Audio (default)"
contentBefore={<FolderRegular />}
onChange={(_, d) => update({ audioDir: d.value })}
onChange={(_, d) => update({ audioFolder: d.value })}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
<Button icon={<FolderRegular />} onClick={() => chooseFolder('audioFolder')}>
Browse
</Button>
{audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
{audioFolder && (
<Button appearance="subtle" onClick={() => clearFolder('audioFolder')}>
Reset
</Button>
)}
@@ -141,8 +141,8 @@ export function DownloadsCard(): React.JSX.Element {
hint="When AeroFetch gains focus, offer a video link you've just copied."
>
<Switch
checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })}
checked={watchClipboard}
onChange={(_, d) => update({ watchClipboard: d.checked })}
/>
</Field>
@@ -7,7 +7,7 @@ export function FilenamesCard(): React.JSX.Element {
const styles = useSettingsStyles()
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const useDownloadArchive = useSettings((s) => s.useDownloadArchive)
const update = useSettings((s) => s.update)
return (
@@ -44,8 +44,8 @@ export function FilenamesCard(): React.JSX.Element {
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
>
<Switch
checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })}
checked={useDownloadArchive}
onChange={(_, d) => update({ useDownloadArchive: d.checked })}
/>
</Field>
</Card>
+3 -3
View File
@@ -17,8 +17,8 @@ type Api = Window['api']
// work isn't blocked behind the welcome screen.
const MOCK_SETTINGS: Settings = {
...DEFAULT_SETTINGS,
videoDir: 'C:\\Users\\you\\Documents\\Video',
audioDir: 'C:\\Users\\you\\Documents\\Audio',
videoFolder: 'C:\\Users\\you\\Documents\\Video',
audioFolder: 'C:\\Users\\you\\Documents\\Audio',
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
autoDownloadNew: true,
@@ -174,7 +174,7 @@ export const mockApi: Api = {
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioFolder : MOCK_SETTINGS.videoFolder
return {
ok: true,
command: `yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
+6 -6
View File
@@ -12,8 +12,8 @@ import { logError } from '../reportError'
const FALLBACK: Settings = PREVIEW
? {
...DEFAULT_SETTINGS,
videoDir: 'C:\\Users\\you\\Documents\\Video',
audioDir: 'C:\\Users\\you\\Documents\\Audio',
videoFolder: 'C:\\Users\\you\\Documents\\Video',
audioFolder: 'C:\\Users\\you\\Documents\\Audio',
hasCompletedOnboarding: true
}
: DEFAULT_SETTINGS
@@ -25,9 +25,9 @@ interface SettingsState extends Settings {
/** re-fetch persisted settings from main (a backup restore changes them underneath) */
reload: () => void
/** open the OS folder picker and store the result as the video or audio folder */
chooseDir: (target: 'videoDir' | 'audioDir') => void
chooseFolder: (target: 'videoFolder' | 'audioFolder') => void
/** clear a per-kind folder override, restoring its Documents\… default */
clearDir: (target: 'videoDir' | 'audioDir') => void
clearFolder: (target: 'videoFolder' | 'audioFolder') => void
/** open the Windows High Contrast settings page (AppearanceCard) */
openHighContrastSettings: () => void
}
@@ -62,7 +62,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
.catch(logError('getSettings'))
},
chooseDir: (target) => {
chooseFolder: (target) => {
if (PREVIEW) return
// Seed the OS picker with the folder this target currently points at (W5).
window.api
@@ -73,7 +73,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
.catch(logError('chooseFolder'))
},
clearDir: (target) => get().update({ [target]: '' }),
clearFolder: (target) => get().update({ [target]: '' }),
openHighContrastSettings: () => {
if (!PREVIEW) window.api.openHighContrastSettings().catch(logError('openHighContrastSettings'))
+3 -3
View File
@@ -36,7 +36,7 @@ interface ClipboardLink {
/**
* Watches the clipboard on window focus and offers a freshly-copied http(s) link.
* Shared by the download bar and the library's add-source field so both fields
* can auto-suggest a copied URL. Respects the `clipboardWatch` setting and never
* can auto-suggest a copied URL. Respects the `watchClipboard` setting and never
* interrupts text the user is already typing — pass the field's current value as
* `currentValue` and the watcher stays quiet while it's non-empty.
*
@@ -75,9 +75,9 @@ export function useClipboardLink(
async function check(): Promise<void> {
const s = useSettings.getState()
// Don't touch the clipboard until real settings have loaded (L138): before
// that we'd be acting on the pre-load fallback for clipboardWatch instead of
// that we'd be acting on the pre-load fallback for watchClipboard instead of
// the user's actual choice — and we never read it when the watcher is off.
if (!s.loaded || !s.clipboardWatch || valueRef.current.trim()) return
if (!s.loaded || !s.watchClipboard || valueRef.current.trim()) return
let text = ''
try {
text = (await window.api.readClipboard()) ?? ''