Merge Batch 23 — naming & config (CC1, CC11)

This commit is contained in:
2026-07-02 12:34:05 -04:00
25 changed files with 288 additions and 135 deletions
+17 -12
View File
@@ -1941,16 +1941,22 @@ section consolidates them by theme and — the point of the exercise — **recom
*stylistically* consistent (no-semicolon, single-quote, 2-space) and has a genuinely good pure/impure
split — but that style is unenforced and several "do the same thing two ways" seams have crept in.
- [ ] **CC1 — Naming conventions.** Boolean settings have no convention: `useAria2c` (verb-prefix),
- [x] **CC1 — Naming conventions.** Boolean settings have no convention: `useAria2c` (verb-prefix),
`autoUpdateYtdlp` (auto-prefix), `customCommandEnabled` (suffix), `downloadArchive`/`restrictFilenames`
(bare). Keys say `videoDir`/`audioDir` while the UI says "folder." Preload names diverge from main
(L92); `MediaKind` is declared twice (L105). **Standard:** booleans as `is`/`has`/`should`/`<verb>`
consistently; one term ("folder") across keys + UI; align preload↔main names; single-source shared types in `@shared`.
*Partially closed / deferred: `MediaKind` is single-sourced (L105 done). The remaining renames are a
**breaking persistence change** — the boolean/`videoDir` keys are the on-disk `settings.json` schema, so
renaming them silently orphans every existing user's saved settings without a migration. Deferred to a 1.x
settings-migration pass; the preload↔main name alignment is the safe subset, tracked as L92 (Batch 13). The
naming convention is recorded here for new keys.*
*Fixed (Batch 23) — the breaking rename shipped WITH its migration: `customCommandEnabled` →
`enableCustomCommands`, `downloadArchive` → `useDownloadArchive`, `clipboardWatch` → `watchClipboard`,
`sidebarCollapsed` → `isSidebarCollapsed`, `hardwareAcceleration` → `useHardwareAcceleration`, and
`videoDir`/`audioDir` → `videoFolder`/`audioFolder` (plus the store actions `chooseDir`/`clearDir` →
`chooseFolder`/`clearFolder`); already-conforming verbs (`useAria2c`, `notifyOnComplete`, `minimizeToTray`,
`launchAtStartup`, `restrictFilenames`, `autoUpdateYtdlp`, `autoDownloadNew`, `hasCompletedOnboarding`)
kept. The rename map lives in [settingsMigration.ts](src/main/settingsMigration.ts) (pure, unit-tested in
[test/settingsMigration.test.ts](test/settingsMigration.test.ts) incl. a full pre-rename backup fixture)
and is applied in two places: a one-time idempotent on-disk shim when the electron-store opens, and on
backup import so old backup files still restore. Preload↔main names (L92) and single-sourced types (L105)
were already done.*
- [x] **CC2 — Coding style is consistent but unenforced.** No ESLint/Prettier config, script, or dep
(L44), so the (good) house style drifts only by discipline; `??` vs `||` is occasionally misused for
null checks. **Standard:** add Prettier + typescript-eslint with `lint`/`format` scripts in CI; codify the existing style.
@@ -2029,15 +2035,14 @@ split — but that style is unenforced and several "do the same thing two ways"
divergence is gone. The remaining electron-store↔jsonStore split is a **deliberate** keep: settings live in
electron-store for its DPAPI-encrypted secret fields, records in jsonStore. Fully migrating settings off
electron-store is a 1.x call, not forced here.*
- [ ] **CC11 — Configuration is scattered.** `electron-store` + `localStorage` (sidebar, M19) + env vars
- [x] **CC11 — Configuration is scattered.** `electron-store` + `localStorage` (sidebar, M19) + env vars
(`PORTABLE_EXECUTABLE_DIR`, `CSC_LINK`, `AEROFETCH_REAL_DOWNLOAD`) + hardcoded module consts (update
host/owner/repo, timeouts, caps, `09:00`, `ARIA2C_ARGS`, L10). **Standard:** a `config.ts` for build/host
constants; fold `localStorage` UI prefs into the settings store so there's one persisted-prefs source.
*Partly closed / deferred: the runtime consts are already centralized in
[constants.ts](src/main/constants.ts) (L10), and the sidebar `localStorage` pref was folded into the
settings store (M19). What's left — a dedicated `config.ts` for build/host constants (update
host/owner/repo, currently co-located in updater.ts) — is a marginal move deferred to the 1.x organization
pass (CC12), with which it naturally lands.*
*Fixed (Batch 23, the remaining move): build/host constants (update host/owner/repo + the release API URL)
now live in [config.ts](src/main/config.ts), imported by the updater — deploy identity in one file,
runtime tunables in constants.ts (L10), user prefs in settings (localStorage was folded in by M19). The
env vars are genuinely environmental (portable mode, CI signing, integration-test opt-in) and stay env vars.*
- [ ] **CC12 — Project organization.** Renderer `components/` mixes screens (views) with reusable widgets;
helpers (`theme`/`thumb`/`useClipboardLink`) sit at src root; the **pure** `queueStats` lives in `store/`;
main mixes pure (`buildArgs`/`validation`/`indexerCore`/`ytdlpPolicy`) and impure modules in one flat dir.
+9 -4
View File
@@ -1,6 +1,7 @@
import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { migrateSettingsKeys } from './settingsMigration'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
@@ -54,9 +55,13 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
// Backups exported by an older AeroFetch carry pre-rename keys (CC1) — map
// them forward so old backup files keep restoring cleanly.
const incomingSettings =
file.settings && typeof file.settings === 'object'
? (file.settings as Partial<Settings>)
? (migrateSettingsKeys(
file.settings as unknown as Record<string, unknown>
) as Partial<Settings>)
: undefined
// Drop non-object / id-less entries up front (audit T3) so both the consent
// check below and replaceTemplates see only well-shaped rows. Without this a
@@ -67,7 +72,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
// the next download. A backup that arrives with customCommandEnabled + a default
// the next download. A backup that arrives with enableCustomCommands + a default
// template carrying args would silently enable code execution the moment a
// download starts — so require an explicit, informed confirmation before
// honouring that, and import the templates in a *disabled* state if declined.
@@ -75,7 +80,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
)
const wouldEnableCustomCommands =
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
incomingSettings?.enableCustomCommands === true && commandTemplates.length > 0
let applyCustomCommands = true
if (wouldEnableCustomCommands) {
@@ -120,7 +125,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands
? restored
: { ...restored, customCommandEnabled: false }
: { ...restored, enableCustomCommands: false }
setSettings(safeSettings)
}
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
+4 -4
View File
@@ -172,24 +172,24 @@ export function parseExtraArgs(raw: string): string[] {
* per-download override wins over the persisted default template, but NEITHER is
* applied while the gate is off. This keeps a compromised renderer from smuggling
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
* first have to flip the visible `enableCustomCommands` setting, leaving a trace
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
*
* - customCommandEnabled off → [] (always)
* - enableCustomCommands off → [] (always)
* - perDownloadExtraArgs defined (even '') → those args
* - else a template whose urlPattern matches → that template's args (most specific)
* - else a matching defaultTemplateId → that template's args
* - else → []
*/
export function selectExtraArgs(params: {
customCommandEnabled: boolean
enableCustomCommands: boolean
perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
/** the download URL, for urlPattern auto-matching */
url?: string
}): string[] {
if (!params.customCommandEnabled) return []
if (!params.enableCustomCommands) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
// A template whose urlPattern matches this URL auto-applies, ahead of the global
// default — it's the more specific choice.
+15
View File
@@ -0,0 +1,15 @@
/**
* Build/host configuration (CC11): the deploy-specific constants that identify
* WHERE this build talks to, separated from runtime tunables (constants.ts —
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
* fork's update source by editing these three values.
*
* IMPORTANT: the update repo's 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 update
* check simply reports that it couldn't reach the server.
*/
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
export const UPDATE_OWNER = 'debont80'
export const UPDATE_REPO = 'AeroFetch'
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
+5 -5
View File
@@ -166,15 +166,15 @@ async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | nul
// --- Argv construction (shared by startDownload and the command preview) ---
// A per-download override (opts.extraArgs, even '') wins over the persisted
// default template — but BOTH are gated on the customCommandEnabled consent flag
// default template — but BOTH are gated on the enableCustomCommands consent flag
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
// in the renderer UI, so the renderer can't be trusted to apply it.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
// Gate the file read: listTemplates() parses templates.json on every call, so
// skip it entirely when the feature is off (PERF2).
if (!settings.customCommandEnabled) return []
if (!settings.enableCustomCommands) return []
return selectExtraArgs({
customCommandEnabled: settings.customCommandEnabled,
enableCustomCommands: settings.enableCustomCommands,
perDownloadExtraArgs: opts.extraArgs,
defaultTemplateId: settings.defaultTemplateId,
templates: listTemplates(),
@@ -197,7 +197,7 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
const override = opts.outputDir?.trim()
const safeOverride = override && isSafeOutputDir(override) ? override : ''
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim()
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
// A collection (media-manager) download is filed into <channel>/<playlist>/
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
@@ -220,7 +220,7 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
cookiesFile,
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
downloadArchivePath: settings.useDownloadArchive ? getDownloadArchivePath() : undefined,
youtubePlayerClient: settings.youtubePlayerClient,
youtubePoToken: settings.youtubePoToken
}
+1 -1
View File
@@ -44,7 +44,7 @@ setupPortableData()
// safe default; users on modern GPUs can flip Settings → Appearance →
// "Hardware acceleration" (applies on next launch — this must run before the
// app is ready). The window backgroundColor is theme-matched (see createWindow).
if (!getSettings().hardwareAcceleration) {
if (!getSettings().useHardwareAcceleration) {
app.disableHardwareAcceleration()
}
// Native window-occlusion tracking stays off in BOTH modes — it's a separate
+29 -11
View File
@@ -1,6 +1,7 @@
import { app, safeStorage } from 'electron'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
import { logger } from './logger'
import {
AUDIO_FORMATS,
@@ -91,11 +92,28 @@ function sanitizeOptions(input: unknown): DownloadOptions {
}
}
// Constructed lazily — electron-store needs app paths, which exist only after
// the app is ready (all callers run post-ready).
// Constructed lazily — electron-store needs app paths. (`userData` resolves
// pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.)
let store: Store<Settings> | null = null
function getStore(): Store<Settings> {
if (!store) store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
if (!store) {
store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
// CC1 key-rename migration: move any legacy key still on disk to its new
// name, once. `has()` reports defaults as present, so probe the raw store
// object instead; idempotent because the old key is deleted afterwards.
const raw = store.store as unknown as Record<string, unknown>
const legacy = RENAMED_SETTINGS_KEYS.filter(([oldKey]) => oldKey in raw)
if (legacy.length > 0) {
const untyped = store as unknown as {
set(k: string, v: unknown): void
delete(k: string): void
}
for (const [oldKey, newKey] of legacy) {
untyped.set(newKey, raw[oldKey])
untyped.delete(oldKey)
}
}
}
return store
}
@@ -185,7 +203,7 @@ export function getSettings(): Settings {
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
// videoDir/audioDir are intentionally left blank by default — an empty value
// videoFolder/audioFolder are intentionally left blank by default — an empty value
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
// Only an explicit user choice (Settings → folders) overrides that.
// Migrate settings files that predate downloadOptions (or hold a partial one),
@@ -278,18 +296,18 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
// well-formed 24h HH:MM is ever stored (L51).
if (isValidSyncTime(value)) s.set('syncTime', value)
break
case 'clipboardWatch':
case 'watchClipboard':
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
case 'useDownloadArchive':
case 'autoUpdateYtdlp':
case 'customCommandEnabled':
case 'enableCustomCommands':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
case 'minimizeToTray':
case 'sidebarCollapsed':
case 'hardwareAcceleration':
case 'isSidebarCollapsed':
case 'useHardwareAcceleration':
if (typeof value === 'boolean') s.set(key, value)
break
case 'launchAtStartup':
@@ -325,8 +343,8 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
// group (it merges field changes locally before calling setSettings).
s.set('downloadOptions', sanitizeOptions(value))
break
case 'videoDir':
case 'audioDir':
case 'videoFolder':
case 'audioFolder':
// An empty string is allowed — it clears the override and restores the
// Documents\Video / Documents\Audio default for that kind.
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
+40
View File
@@ -0,0 +1,40 @@
/**
* Settings key renames (CC1): boolean keys follow the is/has/should/<verb>
* convention and folder keys say "folder" (matching the UI) instead of "dir".
* The rename is a breaking on-disk change for `settings.json`, so it ships with
* this migration, applied in two places:
*
* - the electron-store shim in settings.ts, which moves any old key found on
* disk to its new name once (idempotent — the old key is deleted after);
* - backup import (backup.ts), so a backup exported by an older AeroFetch
* still restores cleanly.
*
* Pure and unit-tested (test/settingsMigration.test.ts). New settings keys must
* follow the convention up front — this list should only ever grow when a key
* is deliberately renamed.
*/
export const RENAMED_SETTINGS_KEYS: ReadonlyArray<readonly [string, string]> = [
['customCommandEnabled', 'enableCustomCommands'],
['downloadArchive', 'useDownloadArchive'],
['clipboardWatch', 'watchClipboard'],
['sidebarCollapsed', 'isSidebarCollapsed'],
['hardwareAcceleration', 'useHardwareAcceleration'],
['videoDir', 'videoFolder'],
['audioDir', 'audioFolder']
]
/**
* Return a copy of `obj` with every legacy key moved to its new name. A value
* already present under the NEW name wins (the old key is simply dropped), so
* a half-migrated or hand-merged file can't regress a newer value.
*/
export function migrateSettingsKeys(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...obj }
for (const [oldKey, newKey] of RENAMED_SETTINGS_KEYS) {
if (oldKey in out) {
if (!(newKey in out)) out[newKey] = out[oldKey]
delete out[oldKey]
}
}
return out
}
+2 -2
View File
@@ -3,7 +3,7 @@
* arguments and streams stdout/stderr back to the renderer line-by-line.
*
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
* and the whole feature is gated on Settings.enableCustomCommands — the same consent
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
*/
@@ -25,7 +25,7 @@ function send(wc: WebContents, ev: TerminalEvent): void {
}
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
if (!getSettings().customCommandEnabled) {
if (!getSettings().enableCustomCommands) {
return {
ok: false,
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
+4 -11
View File
@@ -4,6 +4,7 @@ import { stat, unlink } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { createHash } from 'crypto'
import { getSettings } from './settings'
import { UPDATE_HOST, RELEASE_API } from './config'
import {
IpcChannels,
type AppUpdateInfo,
@@ -28,18 +29,10 @@ function authHeader(): Record<string, string> {
}
// --- 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
// The Gitea repo whose Releases host the AeroFetch installers lives in
// config.ts (CC11) with the other build/host constants. The updater reads the
// repo's latest release over the public REST API and downloads the installer
// asset attached to it.
//
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
// AeroFetch never ships a token; on a sign-in-required instance the check simply
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
const UPDATE_OWNER = 'debont80'
const UPDATE_REPO = 'AeroFetch'
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
// Security: only ever download or execute a file served by the trusted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
+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()) ?? ''
+17 -17
View File
@@ -451,7 +451,7 @@ export interface StartDownloadOptions {
/**
* Per-download custom-command override: raw extra yt-dlp flags appended to
* the generated argv (see CommandTemplate). Omit to fall back to the
* persisted settings default (customCommandEnabled + defaultTemplateId);
* persisted settings default (enableCustomCommands + defaultTemplateId);
* pass an empty string to explicitly run with no extra args for just this
* download even when the settings default is enabled.
*/
@@ -555,9 +555,9 @@ export interface PersistedQueueItem {
/** Persisted user settings (electron-store). */
export interface Settings {
/** where video downloads are saved; empty string = the default Documents\Video folder */
videoDir: string
videoFolder: string
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
audioDir: string
audioFolder: string
defaultKind: MediaKind
defaultVideoQuality: string
defaultAudioQuality: string
@@ -570,7 +570,7 @@ export interface Settings {
/** brand accent preset (buttons, links, selected nav item) */
accentColor: AccentColor
/** auto-detect video links from the clipboard on window focus */
clipboardWatch: boolean
watchClipboard: boolean
/** default post-processing / format options for new downloads */
downloadOptions: DownloadOptions
/** yt-dlp --proxy value, e.g. 'socks5://127.0.0.1:1080'. Empty disables it. */
@@ -599,7 +599,7 @@ export interface Settings {
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
downloadArchive: boolean
useDownloadArchive: boolean
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
autoUpdateYtdlp: boolean
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
@@ -607,8 +607,8 @@ export interface Settings {
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
ytdlpLastUpdateCheck: number
/** apply a named custom-command template's extra args to every new download */
customCommandEnabled: boolean
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
enableCustomCommands: boolean
/** id of the CommandTemplate applied when enableCustomCommands is on; null = none picked yet */
defaultTemplateId: string | null
/** show a native OS notification when a download finishes or fails */
notifyOnComplete: boolean
@@ -623,7 +623,7 @@ export interface Settings {
/** launch AeroFetch automatically when the user signs in to Windows */
launchAtStartup: boolean
/** whether the navigation sidebar is collapsed to icon-only mode */
sidebarCollapsed: boolean
isSidebarCollapsed: boolean
/**
* Opt in to GPU compositing (W15; takes effect on next launch). Off by
* default: software rendering is the safe choice for this app's target
@@ -631,7 +631,7 @@ export interface Settings {
* case) Chromium's GPU compositor paints the whole window blank. Users on
* modern GPUs can turn this on for smoother high-DPI/large-window rendering.
*/
hardwareAcceleration: boolean
useHardwareAcceleration: boolean
/**
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
* only where the release repo allows anonymous access). When the release repo
@@ -652,8 +652,8 @@ export interface Settings {
export const DEFAULT_SETTINGS: Settings = {
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoDir: '',
audioDir: '',
videoFolder: '',
audioFolder: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best',
@@ -666,7 +666,7 @@ export const DEFAULT_SETTINGS: Settings = {
// Off on first launch (SR4): reading the clipboard on every window focus is a
// privacy surprise for a brand-new user. It's a one-toggle opt-in in Settings
// (the onboarding tip points there). Existing installs keep their saved value.
clipboardWatch: false,
watchClipboard: false,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
@@ -677,7 +677,7 @@ export const DEFAULT_SETTINGS: Settings = {
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
useDownloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// configured channel so a stale binary can't silently cause YouTube 403s.
autoUpdateYtdlp: true,
@@ -686,7 +686,7 @@ export const DEFAULT_SETTINGS: Settings = {
// can switch to nightly in Settings → Software.
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
enableCustomCommands: false,
defaultTemplateId: null,
notifyOnComplete: true,
// Default off so adding a watched channel doesn't silently fill the user's
@@ -696,8 +696,8 @@ export const DEFAULT_SETTINGS: Settings = {
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
sidebarCollapsed: false,
hardwareAcceleration: false,
isSidebarCollapsed: false,
useHardwareAcceleration: false,
updateToken: ''
}
@@ -903,7 +903,7 @@ export interface ScheduledSyncStatus {
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
// and streams its output. The binary is fixed to yt-dlp (never arbitrary exes),
// and the feature is gated on the same customCommandEnabled consent flag as the
// and the feature is gated on the same enableCustomCommands consent flag as the
// per-download extra args (extra args can run code via --exec — audit F2).
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
+7 -7
View File
@@ -389,7 +389,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
expect(
selectExtraArgs({
customCommandEnabled: false,
enableCustomCommands: false,
perDownloadExtraArgs: '--exec "calc.exe"',
defaultTemplateId: 'danger',
templates
@@ -400,7 +400,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
it('returns [] when disabled even if a default template id is set', () => {
expect(
selectExtraArgs({
customCommandEnabled: false,
enableCustomCommands: false,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
@@ -411,7 +411,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
it('parses a per-download override when enabled', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
enableCustomCommands: true,
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
defaultTemplateId: null,
templates
@@ -422,7 +422,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
it('an explicit empty override yields [] even with a default template set', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
enableCustomCommands: true,
perDownloadExtraArgs: '',
defaultTemplateId: 'thumb',
templates
@@ -433,7 +433,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
it('falls back to the default template when no per-download override is given', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
enableCustomCommands: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
@@ -444,7 +444,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
it('returns [] when the default template id matches nothing', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
enableCustomCommands: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'missing',
templates
@@ -550,7 +550,7 @@ describe('format sorting (-S)', () => {
describe('selectExtraArgs url-pattern matching', () => {
const base = {
customCommandEnabled: true,
enableCustomCommands: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: null as string | null
}
+3 -3
View File
@@ -57,21 +57,21 @@ function bench(items: DownloadItem[], maxConcurrent: number, iterations: number)
describe('pump() cost at scale (PERF3)', () => {
it('a full scan+promote over 5,000 items is well under a millisecond-scale budget', () => {
const median = bench(makeItems(5_000), 3 + 1_250, 50) // slots open → full pipeline
console.log(`pump pipeline @5k items: median ${median.toFixed(3)} ms`)
expect(median).toBeLessThan(5)
})
it('a full scan+promote over 20,000 items stays inside a 5 ms budget', () => {
const median = bench(makeItems(20_000), 3 + 5_000, 50)
console.log(`pump pipeline @20k items: median ${median.toFixed(3)} ms`)
expect(median).toBeLessThan(5)
})
it('the no-free-slot fast path (the per-progress-event case) is trivially cheap', () => {
const median = bench(makeItems(20_000), 1, 100) // running >= cap → early return
console.log(`pump no-slot path @20k items: median ${median.toFixed(3)} ms`)
expect(median).toBeLessThan(2)
})
+1 -1
View File
@@ -368,7 +368,7 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
expect(base, 'restricted to [A-Za-z0-9._-]').toMatch(/^[A-Za-z0-9._-]+$/)
}, 240_000)
it('downloadArchive: a second run of the same URL is skipped via the archive', () => {
it('useDownloadArchive: a second run of the same URL is skipped via the archive', () => {
const archive = join(outDir, 'archive.txt')
const mk = (id: string): string[] =>
buildArgs({
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { migrateSettingsKeys, RENAMED_SETTINGS_KEYS } from '../src/main/settingsMigration'
import { DEFAULT_SETTINGS } from '@shared/ipc'
describe('migrateSettingsKeys (CC1)', () => {
it('moves every legacy key to its new name', () => {
const legacy = {
customCommandEnabled: true,
downloadArchive: true,
clipboardWatch: false,
sidebarCollapsed: true,
hardwareAcceleration: true,
videoDir: 'D:\\Media\\Video',
audioDir: 'D:\\Media\\Audio'
}
const out = migrateSettingsKeys(legacy)
expect(out).toEqual({
enableCustomCommands: true,
useDownloadArchive: true,
watchClipboard: false,
isSidebarCollapsed: true,
useHardwareAcceleration: true,
videoFolder: 'D:\\Media\\Video',
audioFolder: 'D:\\Media\\Audio'
})
})
it('a value already present under the new name wins', () => {
const out = migrateSettingsKeys({ videoDir: 'old', videoFolder: 'new' })
expect(out).toEqual({ videoFolder: 'new' })
})
it('passes non-renamed keys through untouched and does not mutate the input', () => {
const input = { theme: 'dark', maxConcurrent: 3, videoDir: 'X' }
const out = migrateSettingsKeys(input)
expect(out.theme).toBe('dark')
expect(out.maxConcurrent).toBe(3)
expect(input.videoDir).toBe('X') // input untouched
expect('videoDir' in out).toBe(false)
})
it('restores a full pre-rename backup fixture into the current Settings shape', () => {
// A settings object exactly as an older AeroFetch would have exported it:
// current defaults, but with every renamed key under its OLD name.
const legacyBackup: Record<string, unknown> = { ...DEFAULT_SETTINGS }
for (const [oldKey, newKey] of RENAMED_SETTINGS_KEYS) {
legacyBackup[oldKey] = legacyBackup[newKey]
delete legacyBackup[newKey]
}
legacyBackup.customCommandEnabled = true
legacyBackup.videoDir = 'E:\\Downloads'
const out = migrateSettingsKeys(legacyBackup)
// Every current key present, no legacy key left behind.
for (const [oldKey, newKey] of RENAMED_SETTINGS_KEYS) {
expect(oldKey in out).toBe(false)
expect(newKey in out).toBe(true)
}
expect(out.enableCustomCommands).toBe(true)
expect(out.videoFolder).toBe('E:\\Downloads')
expect(Object.keys(out).sort()).toEqual(Object.keys(DEFAULT_SETTINGS).sort())
})
it('every new name follows the boolean/folder naming convention', () => {
for (const [, newKey] of RENAMED_SETTINGS_KEYS) {
expect(newKey).toMatch(/^(is|has|should|use|enable|watch)[A-Z]|Folder$/)
}
})
})