diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 1925b43..92f8d0f 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -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`/`` 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. diff --git a/src/main/backup.ts b/src/main/backup.ts index 9aa58f4..7373a28 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -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 + // 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) + ? (migrateSettingsKeys( + file.settings as unknown as Record + ) as Partial) : 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 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 0 || Array.isArray(file.templates)) { diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index 8ad30a5..9105f34 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -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[] /** 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. diff --git a/src/main/config.ts b/src/main/config.ts new file mode 100644 index 0000000..2c868bd --- /dev/null +++ b/src/main/config.ts @@ -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` diff --git a/src/main/download.ts b/src/main/download.ts index 8e63a48..80d06ff 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -166,15 +166,15 @@ async function probeMeta(ytdlp: string, url: string): Promise// // - 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 } diff --git a/src/main/index.ts b/src/main/index.ts index 3b97e4d..9976a99 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/settings.ts b/src/main/settings.ts index 11afbd5..3037b27 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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()))) { diff --git a/src/main/settingsMigration.ts b/src/main/settingsMigration.ts new file mode 100644 index 0000000..085883f --- /dev/null +++ b/src/main/settingsMigration.ts @@ -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 +} diff --git a/src/main/terminal.ts b/src/main/terminal.ts index 99e27a0..3b37e54 100644 --- a/src/main/terminal.ts +++ b/src/main/terminal.ts @@ -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.' diff --git a/src/main/updater.ts b/src/main/updater.ts index ade8d97..c63117c 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5762253..d62f691 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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. diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx index 97872b1..07112b0 100644 --- a/src/renderer/src/components/Onboarding.tsx +++ b/src/renderer/src/components/Onboarding.tsx @@ -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> diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 520629a..757bb6c 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -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> diff --git a/src/renderer/src/components/settings/AppearanceCard.tsx b/src/renderer/src/components/settings/AppearanceCard.tsx index aeed6ba..48e1ab4 100644 --- a/src/renderer/src/components/settings/AppearanceCard.tsx +++ b/src/renderer/src/components/settings/AppearanceCard.tsx @@ -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> diff --git a/src/renderer/src/components/settings/CustomCommandsCard.tsx b/src/renderer/src/components/settings/CustomCommandsCard.tsx index 7989036..d97c380 100644 --- a/src/renderer/src/components/settings/CustomCommandsCard.tsx +++ b/src/renderer/src/components/settings/CustomCommandsCard.tsx @@ -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'} diff --git a/src/renderer/src/components/settings/DownloadsCard.tsx b/src/renderer/src/components/settings/DownloadsCard.tsx index b248dec..9328ec1 100644 --- a/src/renderer/src/components/settings/DownloadsCard.tsx +++ b/src/renderer/src/components/settings/DownloadsCard.tsx @@ -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> diff --git a/src/renderer/src/components/settings/FilenamesCard.tsx b/src/renderer/src/components/settings/FilenamesCard.tsx index 7521a09..5b7f26d 100644 --- a/src/renderer/src/components/settings/FilenamesCard.tsx +++ b/src/renderer/src/components/settings/FilenamesCard.tsx @@ -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> diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts index 52050d2..1b931a2 100644 --- a/src/renderer/src/mockApi.ts +++ b/src/renderer/src/mockApi.ts @@ -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}` diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 19b926e..ff6a2b9 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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')) diff --git a/src/renderer/src/useClipboardLink.ts b/src/renderer/src/useClipboardLink.ts index 22c94b5..718d946 100644 --- a/src/renderer/src/useClipboardLink.ts +++ b/src/renderer/src/useClipboardLink.ts @@ -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()) ?? '' diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5c7d021..18f1031 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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. */ diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index 9f0d6ef..ada0422 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -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 } diff --git a/test/pumpBench.test.ts b/test/pumpBench.test.ts index e27426e..06c14c1 100644 --- a/test/pumpBench.test.ts +++ b/test/pumpBench.test.ts @@ -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) }) diff --git a/test/real-download.integration.test.ts b/test/real-download.integration.test.ts index 967aea0..712498d 100644 --- a/test/real-download.integration.test.ts +++ b/test/real-download.integration.test.ts @@ -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({ diff --git a/test/settingsMigration.test.ts b/test/settingsMigration.test.ts new file mode 100644 index 0000000..4baec6e --- /dev/null +++ b/test/settingsMigration.test.ts @@ -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$/) + } + }) +})