feat(audit): Batch 23 — settings key renames with migration (CC1) + config.ts (CC11)
CC1: customCommandEnabled→enableCustomCommands, downloadArchive→ useDownloadArchive, clipboardWatch→watchClipboard, sidebarCollapsed→ isSidebarCollapsed, hardwareAcceleration→useHardwareAcceleration, videoDir/audioDir→videoFolder/audioFolder (+ chooseDir/clearDir store actions → chooseFolder/clearFolder). Rename map in settingsMigration.ts (pure, unit-tested incl. pre-rename backup fixture), applied as an idempotent on-disk shim at store open and on backup import so old settings.json and old backups both keep working. CC11: update host/owner/repo + release API moved to src/main/config.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+17
-12
@@ -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
|
*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.
|
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`
|
`autoUpdateYtdlp` (auto-prefix), `customCommandEnabled` (suffix), `downloadArchive`/`restrictFilenames`
|
||||||
(bare). Keys say `videoDir`/`audioDir` while the UI says "folder." Preload names diverge from main
|
(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>`
|
(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`.
|
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
|
*Fixed (Batch 23) — the breaking rename shipped WITH its migration: `customCommandEnabled` →
|
||||||
**breaking persistence change** — the boolean/`videoDir` keys are the on-disk `settings.json` schema, so
|
`enableCustomCommands`, `downloadArchive` → `useDownloadArchive`, `clipboardWatch` → `watchClipboard`,
|
||||||
renaming them silently orphans every existing user's saved settings without a migration. Deferred to a 1.x
|
`sidebarCollapsed` → `isSidebarCollapsed`, `hardwareAcceleration` → `useHardwareAcceleration`, and
|
||||||
settings-migration pass; the preload↔main name alignment is the safe subset, tracked as L92 (Batch 13). The
|
`videoDir`/`audioDir` → `videoFolder`/`audioFolder` (plus the store actions `chooseDir`/`clearDir` →
|
||||||
naming convention is recorded here for new keys.*
|
`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
|
- [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
|
(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.
|
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
|
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 for its DPAPI-encrypted secret fields, records in jsonStore. Fully migrating settings off
|
||||||
electron-store is a 1.x call, not forced here.*
|
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
|
(`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
|
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.
|
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
|
*Fixed (Batch 23, the remaining move): build/host constants (update host/owner/repo + the release API URL)
|
||||||
[constants.ts](src/main/constants.ts) (L10), and the sidebar `localStorage` pref was folded into the
|
now live in [config.ts](src/main/config.ts), imported by the updater — deploy identity in one file,
|
||||||
settings store (M19). What's left — a dedicated `config.ts` for build/host constants (update
|
runtime tunables in constants.ts (L10), user prefs in settings (localStorage was folded in by M19). The
|
||||||
host/owner/repo, currently co-located in updater.ts) — is a marginal move deferred to the 1.x organization
|
env vars are genuinely environmental (portable mode, CI signing, integration-test opt-in) and stay env vars.*
|
||||||
pass (CC12), with which it naturally lands.*
|
|
||||||
- [ ] **CC12 — Project organization.** Renderer `components/` mixes screens (views) with reusable widgets;
|
- [ ] **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/`;
|
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.
|
main mixes pure (`buildArgs`/`validation`/`indexerCore`/`ytdlpPolicy`) and impure modules in one flat dir.
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,7 @@
|
|||||||
import { dialog, type BrowserWindow } from 'electron'
|
import { dialog, type BrowserWindow } from 'electron'
|
||||||
import { readFileSync, writeFileSync } from 'fs'
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
import { getSettings, setSettings, SECRET_KEYS } from './settings'
|
import { getSettings, setSettings, SECRET_KEYS } from './settings'
|
||||||
|
import { migrateSettingsKeys } from './settingsMigration'
|
||||||
import { listTemplates, replaceTemplates } from './templates'
|
import { listTemplates, replaceTemplates } from './templates'
|
||||||
import { isTemplateLike } from './validation'
|
import { isTemplateLike } from './validation'
|
||||||
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
|
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
|
// hand-edited or partial backup file degrades gracefully rather than
|
||||||
// corrupting the store.
|
// corrupting the store.
|
||||||
const file = parsed as Partial<BackupFile>
|
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 =
|
const incomingSettings =
|
||||||
file.settings && typeof file.settings === 'object'
|
file.settings && typeof file.settings === 'object'
|
||||||
? (file.settings as Partial<Settings>)
|
? (migrateSettingsKeys(
|
||||||
|
file.settings as unknown as Record<string, unknown>
|
||||||
|
) as Partial<Settings>)
|
||||||
: undefined
|
: undefined
|
||||||
// Drop non-object / id-less entries up front (audit T3) so both the consent
|
// 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
|
// 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
|
// 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
|
// template carrying args would silently enable code execution the moment a
|
||||||
// download starts — so require an explicit, informed confirmation before
|
// download starts — so require an explicit, informed confirmation before
|
||||||
// honouring that, and import the templates in a *disabled* state if declined.
|
// 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() !== ''
|
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
|
||||||
)
|
)
|
||||||
const wouldEnableCustomCommands =
|
const wouldEnableCustomCommands =
|
||||||
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
|
incomingSettings?.enableCustomCommands === true && commandTemplates.length > 0
|
||||||
|
|
||||||
let applyCustomCommands = true
|
let applyCustomCommands = true
|
||||||
if (wouldEnableCustomCommands) {
|
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.
|
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
|
||||||
const safeSettings = applyCustomCommands
|
const safeSettings = applyCustomCommands
|
||||||
? restored
|
? restored
|
||||||
: { ...restored, customCommandEnabled: false }
|
: { ...restored, enableCustomCommands: false }
|
||||||
setSettings(safeSettings)
|
setSettings(safeSettings)
|
||||||
}
|
}
|
||||||
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
||||||
|
|||||||
@@ -172,24 +172,24 @@ export function parseExtraArgs(raw: string): string[] {
|
|||||||
* per-download override wins over the persisted default template, but NEITHER is
|
* 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
|
* applied while the gate is off. This keeps a compromised renderer from smuggling
|
||||||
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
|
* 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).
|
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
|
||||||
*
|
*
|
||||||
* - customCommandEnabled off → [] (always)
|
* - enableCustomCommands off → [] (always)
|
||||||
* - perDownloadExtraArgs defined (even '') → those args
|
* - perDownloadExtraArgs defined (even '') → those args
|
||||||
* - else a template whose urlPattern matches → that template's args (most specific)
|
* - else a template whose urlPattern matches → that template's args (most specific)
|
||||||
* - else a matching defaultTemplateId → that template's args
|
* - else a matching defaultTemplateId → that template's args
|
||||||
* - else → []
|
* - else → []
|
||||||
*/
|
*/
|
||||||
export function selectExtraArgs(params: {
|
export function selectExtraArgs(params: {
|
||||||
customCommandEnabled: boolean
|
enableCustomCommands: boolean
|
||||||
perDownloadExtraArgs: string | undefined
|
perDownloadExtraArgs: string | undefined
|
||||||
defaultTemplateId: string | null
|
defaultTemplateId: string | null
|
||||||
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
|
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
|
||||||
/** the download URL, for urlPattern auto-matching */
|
/** the download URL, for urlPattern auto-matching */
|
||||||
url?: string
|
url?: string
|
||||||
}): string[] {
|
}): string[] {
|
||||||
if (!params.customCommandEnabled) return []
|
if (!params.enableCustomCommands) return []
|
||||||
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
|
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
|
||||||
// A template whose urlPattern matches this URL auto-applies, ahead of the global
|
// A template whose urlPattern matches this URL auto-applies, ahead of the global
|
||||||
// default — it's the more specific choice.
|
// default — it's the more specific choice.
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -166,15 +166,15 @@ async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | nul
|
|||||||
// --- Argv construction (shared by startDownload and the command preview) ---
|
// --- Argv construction (shared by startDownload and the command preview) ---
|
||||||
|
|
||||||
// A per-download override (opts.extraArgs, even '') wins over the persisted
|
// 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
|
// (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.
|
// in the renderer UI, so the renderer can't be trusted to apply it.
|
||||||
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
||||||
// Gate the file read: listTemplates() parses templates.json on every call, so
|
// Gate the file read: listTemplates() parses templates.json on every call, so
|
||||||
// skip it entirely when the feature is off (PERF2).
|
// skip it entirely when the feature is off (PERF2).
|
||||||
if (!settings.customCommandEnabled) return []
|
if (!settings.enableCustomCommands) return []
|
||||||
return selectExtraArgs({
|
return selectExtraArgs({
|
||||||
customCommandEnabled: settings.customCommandEnabled,
|
enableCustomCommands: settings.enableCustomCommands,
|
||||||
perDownloadExtraArgs: opts.extraArgs,
|
perDownloadExtraArgs: opts.extraArgs,
|
||||||
defaultTemplateId: settings.defaultTemplateId,
|
defaultTemplateId: settings.defaultTemplateId,
|
||||||
templates: listTemplates(),
|
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)
|
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
|
||||||
const override = opts.outputDir?.trim()
|
const override = opts.outputDir?.trim()
|
||||||
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
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)
|
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
||||||
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
||||||
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
// <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,
|
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||||
cookiesFile,
|
cookiesFile,
|
||||||
restrictFilenames: settings.restrictFilenames,
|
restrictFilenames: settings.restrictFilenames,
|
||||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
downloadArchivePath: settings.useDownloadArchive ? getDownloadArchivePath() : undefined,
|
||||||
youtubePlayerClient: settings.youtubePlayerClient,
|
youtubePlayerClient: settings.youtubePlayerClient,
|
||||||
youtubePoToken: settings.youtubePoToken
|
youtubePoToken: settings.youtubePoToken
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ setupPortableData()
|
|||||||
// safe default; users on modern GPUs can flip Settings → Appearance →
|
// safe default; users on modern GPUs can flip Settings → Appearance →
|
||||||
// "Hardware acceleration" (applies on next launch — this must run before the
|
// "Hardware acceleration" (applies on next launch — this must run before the
|
||||||
// app is ready). The window backgroundColor is theme-matched (see createWindow).
|
// app is ready). The window backgroundColor is theme-matched (see createWindow).
|
||||||
if (!getSettings().hardwareAcceleration) {
|
if (!getSettings().useHardwareAcceleration) {
|
||||||
app.disableHardwareAcceleration()
|
app.disableHardwareAcceleration()
|
||||||
}
|
}
|
||||||
// Native window-occlusion tracking stays off in BOTH modes — it's a separate
|
// Native window-occlusion tracking stays off in BOTH modes — it's a separate
|
||||||
|
|||||||
+29
-11
@@ -1,6 +1,7 @@
|
|||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||||
|
import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
import {
|
import {
|
||||||
AUDIO_FORMATS,
|
AUDIO_FORMATS,
|
||||||
@@ -91,11 +92,28 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constructed lazily — electron-store needs app paths, which exist only after
|
// Constructed lazily — electron-store needs app paths. (`userData` resolves
|
||||||
// the app is ready (all callers run post-ready).
|
// pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.)
|
||||||
let store: Store<Settings> | null = null
|
let store: Store<Settings> | null = null
|
||||||
function getStore(): Store<Settings> {
|
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
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +203,7 @@ export function getSettings(): Settings {
|
|||||||
// `set`, so only write when something actually changed — otherwise this churns
|
// `set`, so only write when something actually changed — otherwise this churns
|
||||||
// the settings file on every read. (audit P1)
|
// the settings file on every read. (audit P1)
|
||||||
const cur = s.store
|
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).
|
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||||
// Only an explicit user choice (Settings → folders) overrides that.
|
// Only an explicit user choice (Settings → folders) overrides that.
|
||||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
// 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).
|
// well-formed 24h HH:MM is ever stored (L51).
|
||||||
if (isValidSyncTime(value)) s.set('syncTime', value)
|
if (isValidSyncTime(value)) s.set('syncTime', value)
|
||||||
break
|
break
|
||||||
case 'clipboardWatch':
|
case 'watchClipboard':
|
||||||
case 'useAria2c':
|
case 'useAria2c':
|
||||||
case 'restrictFilenames':
|
case 'restrictFilenames':
|
||||||
case 'downloadArchive':
|
case 'useDownloadArchive':
|
||||||
case 'autoUpdateYtdlp':
|
case 'autoUpdateYtdlp':
|
||||||
case 'customCommandEnabled':
|
case 'enableCustomCommands':
|
||||||
case 'notifyOnComplete':
|
case 'notifyOnComplete':
|
||||||
case 'autoDownloadNew':
|
case 'autoDownloadNew':
|
||||||
case 'hasCompletedOnboarding':
|
case 'hasCompletedOnboarding':
|
||||||
case 'minimizeToTray':
|
case 'minimizeToTray':
|
||||||
case 'sidebarCollapsed':
|
case 'isSidebarCollapsed':
|
||||||
case 'hardwareAcceleration':
|
case 'useHardwareAcceleration':
|
||||||
if (typeof value === 'boolean') s.set(key, value)
|
if (typeof value === 'boolean') s.set(key, value)
|
||||||
break
|
break
|
||||||
case 'launchAtStartup':
|
case 'launchAtStartup':
|
||||||
@@ -325,8 +343,8 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
|
|||||||
// group (it merges field changes locally before calling setSettings).
|
// group (it merges field changes locally before calling setSettings).
|
||||||
s.set('downloadOptions', sanitizeOptions(value))
|
s.set('downloadOptions', sanitizeOptions(value))
|
||||||
break
|
break
|
||||||
case 'videoDir':
|
case 'videoFolder':
|
||||||
case 'audioDir':
|
case 'audioFolder':
|
||||||
// An empty string is allowed — it clears the override and restores the
|
// An empty string is allowed — it clears the override and restores the
|
||||||
// Documents\Video / Documents\Audio default for that kind.
|
// Documents\Video / Documents\Audio default for that kind.
|
||||||
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* arguments and streams stdout/stderr back to the renderer line-by-line.
|
* 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),
|
* 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
|
* 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.
|
* 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 {
|
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
|
||||||
if (!getSettings().customCommandEnabled) {
|
if (!getSettings().enableCustomCommands) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
|
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
|
||||||
|
|||||||
+4
-11
@@ -4,6 +4,7 @@ import { stat, unlink } from 'fs/promises'
|
|||||||
import { join, normalize, dirname } from 'path'
|
import { join, normalize, dirname } from 'path'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { getSettings } from './settings'
|
import { getSettings } from './settings'
|
||||||
|
import { UPDATE_HOST, RELEASE_API } from './config'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
type AppUpdateInfo,
|
type AppUpdateInfo,
|
||||||
@@ -28,18 +29,10 @@ function authHeader(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Update source -----------------------------------------------------------
|
// --- Update source -----------------------------------------------------------
|
||||||
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
// The Gitea repo whose Releases host the AeroFetch installers lives in
|
||||||
// the repo's latest release over the public REST API and downloads the installer
|
// 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.
|
// 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
|
// 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
|
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function App(): React.JSX.Element {
|
|||||||
const theme = useSettings((s) => s.theme)
|
const theme = useSettings((s) => s.theme)
|
||||||
const accentColor = useSettings((s) => s.accentColor)
|
const accentColor = useSettings((s) => s.accentColor)
|
||||||
const updateSettings = useSettings((s) => s.update)
|
const updateSettings = useSettings((s) => s.update)
|
||||||
const showTerminal = useSettings((s) => s.customCommandEnabled)
|
const showTerminal = useSettings((s) => s.enableCustomCommands)
|
||||||
const isDark = useResolvedDark()
|
const isDark = useResolvedDark()
|
||||||
const tab = useNav((s) => s.tab)
|
const tab = useNav((s) => s.tab)
|
||||||
const setTab = useNav((s) => s.setTab)
|
const setTab = useNav((s) => s.setTab)
|
||||||
@@ -214,9 +214,9 @@ function App(): React.JSX.Element {
|
|||||||
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
|
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const collapsed = useSettings((s) => s.sidebarCollapsed)
|
const collapsed = useSettings((s) => s.isSidebarCollapsed)
|
||||||
function toggleCollapsed(): void {
|
function toggleCollapsed(): void {
|
||||||
updateSettings({ sidebarCollapsed: !collapsed })
|
updateSettings({ isSidebarCollapsed: !collapsed })
|
||||||
}
|
}
|
||||||
// Gate on `loaded` so a returning user's real settings never get clobbered
|
// Gate on `loaded` so a returning user's real settings never get clobbered
|
||||||
// by a one-frame flash of the (default-false) onboarding state.
|
// by a one-frame flash of the (default-false) onboarding state.
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
|||||||
export function Onboarding(): React.JSX.Element {
|
export function Onboarding(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
const videoDir = useSettings((s) => s.videoDir)
|
const videoFolder = useSettings((s) => s.videoFolder)
|
||||||
const audioDir = useSettings((s) => s.audioDir)
|
const audioFolder = useSettings((s) => s.audioFolder)
|
||||||
const chooseDir = useSettings((s) => s.chooseDir)
|
const chooseFolder = useSettings((s) => s.chooseFolder)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -167,11 +167,15 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
<VideoClipRegular className={styles.folderIcon} />
|
<VideoClipRegular className={styles.folderIcon} />
|
||||||
<div className={styles.folderCol}>
|
<div className={styles.folderCol}>
|
||||||
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
||||||
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
|
<Caption1 className={styles.folderPath} title={videoFolder || 'Documents\\Video'}>
|
||||||
{videoDir || 'Documents\\Video'}
|
{videoFolder || 'Documents\\Video'}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FolderRegular />}
|
||||||
|
onClick={() => chooseFolder('videoFolder')}
|
||||||
|
>
|
||||||
Choose…
|
Choose…
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,11 +183,15 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
<MusicNote2Regular className={styles.folderIcon} />
|
<MusicNote2Regular className={styles.folderIcon} />
|
||||||
<div className={styles.folderCol}>
|
<div className={styles.folderCol}>
|
||||||
<Caption1 className={styles.folderLabel}>Audio</Caption1>
|
<Caption1 className={styles.folderLabel}>Audio</Caption1>
|
||||||
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
|
<Caption1 className={styles.folderPath} title={audioFolder || 'Documents\\Audio'}>
|
||||||
{audioDir || 'Documents\\Audio'}
|
{audioFolder || 'Documents\\Audio'}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FolderRegular />}
|
||||||
|
onClick={() => chooseFolder('audioFolder')}
|
||||||
|
>
|
||||||
Choose…
|
Choose…
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,13 +61,13 @@ const useStyles = makeStyles({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
* 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).
|
* flag (enforced again in main -- see src/main/terminal.ts).
|
||||||
*/
|
*/
|
||||||
export function TerminalView(): React.JSX.Element {
|
export function TerminalView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const screen = useScreenStyles()
|
const screen = useScreenStyles()
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
const enableCustomCommands = useSettings((s) => s.enableCustomCommands)
|
||||||
const updateSettings = useSettings((s) => s.update)
|
const updateSettings = useSettings((s) => s.update)
|
||||||
|
|
||||||
// View-model hook owns the run/stream/stop orchestration + IPC (L93/CC13).
|
// 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}>
|
<div className={styles.gate}>
|
||||||
<Body1>
|
<Body1>
|
||||||
The terminal runs the bundled yt-dlp with your own arguments — part of custom
|
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
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => updateSettings({ customCommandEnabled: true })}
|
onClick={() => updateSettings({ enableCustomCommands: true })}
|
||||||
>
|
>
|
||||||
Enable custom commands
|
Enable custom commands
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,7 +132,7 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
placeholder="--version (Ctrl+Enter to run)"
|
placeholder="--version (Ctrl+Enter to run)"
|
||||||
resize="vertical"
|
resize="vertical"
|
||||||
disabled={!customCommandEnabled}
|
disabled={!enableCustomCommands}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
@@ -145,7 +145,7 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<PlayRegular />}
|
icon={<PlayRegular />}
|
||||||
onClick={run}
|
onClick={run}
|
||||||
disabled={!customCommandEnabled || !args.trim()}
|
disabled={!enableCustomCommands || !args.trim()}
|
||||||
>
|
>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function AppearanceCard(): React.JSX.Element {
|
|||||||
const focus = useFocusStyles()
|
const focus = useFocusStyles()
|
||||||
const theme = useSettings((s) => s.theme)
|
const theme = useSettings((s) => s.theme)
|
||||||
const accentColor = useSettings((s) => s.accentColor)
|
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 highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
// Store action wraps the IPC call (L93/CC13: no window.api in components).
|
// 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."
|
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
|
<Switch
|
||||||
checked={hardwareAcceleration}
|
checked={useHardwareAcceleration}
|
||||||
onChange={(_, d) => update({ hardwareAcceleration: d.checked })}
|
onChange={(_, d) => update({ useHardwareAcceleration: d.checked })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useSettingsStyles } from './settingsStyles'
|
|||||||
|
|
||||||
export function CustomCommandsCard(): React.JSX.Element {
|
export function CustomCommandsCard(): React.JSX.Element {
|
||||||
const styles = useSettingsStyles()
|
const styles = useSettingsStyles()
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
const enableCustomCommands = useSettings((s) => s.enableCustomCommands)
|
||||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
const templates = useTemplates((s) => s.templates)
|
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)."
|
hint="Applies the default template below to every new download (still overridable per download)."
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={customCommandEnabled}
|
checked={enableCustomCommands}
|
||||||
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
|
onChange={(_, d) => update({ enableCustomCommands: d.checked })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{customCommandEnabled && (
|
{enableCustomCommands && (
|
||||||
<Field label="Default template">
|
<Field label="Default template">
|
||||||
<Select
|
<Select
|
||||||
value={defaultTemplateId ?? 'none'}
|
value={defaultTemplateId ?? 'none'}
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ import { useSettingsStyles } from './settingsStyles'
|
|||||||
|
|
||||||
export function DownloadsCard(): React.JSX.Element {
|
export function DownloadsCard(): React.JSX.Element {
|
||||||
const styles = useSettingsStyles()
|
const styles = useSettingsStyles()
|
||||||
const videoDir = useSettings((s) => s.videoDir)
|
const videoFolder = useSettings((s) => s.videoFolder)
|
||||||
const audioDir = useSettings((s) => s.audioDir)
|
const audioFolder = useSettings((s) => s.audioFolder)
|
||||||
const chooseDir = useSettings((s) => s.chooseDir)
|
const chooseFolder = useSettings((s) => s.chooseFolder)
|
||||||
const clearDir = useSettings((s) => s.clearDir)
|
const clearFolder = useSettings((s) => s.clearFolder)
|
||||||
const defaultKind = useSettings((s) => s.defaultKind)
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
const maxConcurrent = useSettings((s) => s.maxConcurrent)
|
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 notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||||
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||||||
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
||||||
@@ -58,16 +58,16 @@ export function DownloadsCard(): React.JSX.Element {
|
|||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={videoDir}
|
value={videoFolder}
|
||||||
placeholder="Documents\Video (default)"
|
placeholder="Documents\Video (default)"
|
||||||
contentBefore={<FolderRegular />}
|
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…
|
Browse…
|
||||||
</Button>
|
</Button>
|
||||||
{videoDir && (
|
{videoFolder && (
|
||||||
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
|
<Button appearance="subtle" onClick={() => clearFolder('videoFolder')}>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -81,16 +81,16 @@ export function DownloadsCard(): React.JSX.Element {
|
|||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={audioDir}
|
value={audioFolder}
|
||||||
placeholder="Documents\Audio (default)"
|
placeholder="Documents\Audio (default)"
|
||||||
contentBefore={<FolderRegular />}
|
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…
|
Browse…
|
||||||
</Button>
|
</Button>
|
||||||
{audioDir && (
|
{audioFolder && (
|
||||||
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
|
<Button appearance="subtle" onClick={() => clearFolder('audioFolder')}>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -141,8 +141,8 @@ export function DownloadsCard(): React.JSX.Element {
|
|||||||
hint="When AeroFetch gains focus, offer a video link you've just copied."
|
hint="When AeroFetch gains focus, offer a video link you've just copied."
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={clipboardWatch}
|
checked={watchClipboard}
|
||||||
onChange={(_, d) => update({ clipboardWatch: d.checked })}
|
onChange={(_, d) => update({ watchClipboard: d.checked })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function FilenamesCard(): React.JSX.Element {
|
|||||||
const styles = useSettingsStyles()
|
const styles = useSettingsStyles()
|
||||||
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||||||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||||
const downloadArchive = useSettings((s) => s.downloadArchive)
|
const useDownloadArchive = useSettings((s) => s.useDownloadArchive)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
return (
|
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."
|
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={downloadArchive}
|
checked={useDownloadArchive}
|
||||||
onChange={(_, d) => update({ downloadArchive: d.checked })}
|
onChange={(_, d) => update({ useDownloadArchive: d.checked })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ type Api = Window['api']
|
|||||||
// work isn't blocked behind the welcome screen.
|
// work isn't blocked behind the welcome screen.
|
||||||
const MOCK_SETTINGS: Settings = {
|
const MOCK_SETTINGS: Settings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
videoFolder: 'C:\\Users\\you\\Documents\\Video',
|
||||||
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
audioFolder: 'C:\\Users\\you\\Documents\\Audio',
|
||||||
ytdlpChannel: 'nightly',
|
ytdlpChannel: 'nightly',
|
||||||
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
|
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
|
||||||
autoDownloadNew: true,
|
autoDownloadNew: true,
|
||||||
@@ -174,7 +174,7 @@ export const mockApi: Api = {
|
|||||||
previewCommand: async (opts) => {
|
previewCommand: async (opts) => {
|
||||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
command: `yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
command: `yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { logError } from '../reportError'
|
|||||||
const FALLBACK: Settings = PREVIEW
|
const FALLBACK: Settings = PREVIEW
|
||||||
? {
|
? {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
videoFolder: 'C:\\Users\\you\\Documents\\Video',
|
||||||
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
audioFolder: 'C:\\Users\\you\\Documents\\Audio',
|
||||||
hasCompletedOnboarding: true
|
hasCompletedOnboarding: true
|
||||||
}
|
}
|
||||||
: DEFAULT_SETTINGS
|
: DEFAULT_SETTINGS
|
||||||
@@ -25,9 +25,9 @@ interface SettingsState extends Settings {
|
|||||||
/** re-fetch persisted settings from main (a backup restore changes them underneath) */
|
/** re-fetch persisted settings from main (a backup restore changes them underneath) */
|
||||||
reload: () => void
|
reload: () => void
|
||||||
/** open the OS folder picker and store the result as the video or audio folder */
|
/** 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 */
|
/** 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) */
|
/** open the Windows High Contrast settings page (AppearanceCard) */
|
||||||
openHighContrastSettings: () => void
|
openHighContrastSettings: () => void
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
.catch(logError('getSettings'))
|
.catch(logError('getSettings'))
|
||||||
},
|
},
|
||||||
|
|
||||||
chooseDir: (target) => {
|
chooseFolder: (target) => {
|
||||||
if (PREVIEW) return
|
if (PREVIEW) return
|
||||||
// Seed the OS picker with the folder this target currently points at (W5).
|
// Seed the OS picker with the folder this target currently points at (W5).
|
||||||
window.api
|
window.api
|
||||||
@@ -73,7 +73,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
.catch(logError('chooseFolder'))
|
.catch(logError('chooseFolder'))
|
||||||
},
|
},
|
||||||
|
|
||||||
clearDir: (target) => get().update({ [target]: '' }),
|
clearFolder: (target) => get().update({ [target]: '' }),
|
||||||
|
|
||||||
openHighContrastSettings: () => {
|
openHighContrastSettings: () => {
|
||||||
if (!PREVIEW) window.api.openHighContrastSettings().catch(logError('openHighContrastSettings'))
|
if (!PREVIEW) window.api.openHighContrastSettings().catch(logError('openHighContrastSettings'))
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ interface ClipboardLink {
|
|||||||
/**
|
/**
|
||||||
* Watches the clipboard on window focus and offers a freshly-copied http(s) link.
|
* 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
|
* 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
|
* 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.
|
* `currentValue` and the watcher stays quiet while it's non-empty.
|
||||||
*
|
*
|
||||||
@@ -75,9 +75,9 @@ export function useClipboardLink(
|
|||||||
async function check(): Promise<void> {
|
async function check(): Promise<void> {
|
||||||
const s = useSettings.getState()
|
const s = useSettings.getState()
|
||||||
// Don't touch the clipboard until real settings have loaded (L138): before
|
// 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.
|
// 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 = ''
|
let text = ''
|
||||||
try {
|
try {
|
||||||
text = (await window.api.readClipboard()) ?? ''
|
text = (await window.api.readClipboard()) ?? ''
|
||||||
|
|||||||
+17
-17
@@ -451,7 +451,7 @@ export interface StartDownloadOptions {
|
|||||||
/**
|
/**
|
||||||
* Per-download custom-command override: raw extra yt-dlp flags appended to
|
* Per-download custom-command override: raw extra yt-dlp flags appended to
|
||||||
* the generated argv (see CommandTemplate). Omit to fall back to the
|
* 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
|
* pass an empty string to explicitly run with no extra args for just this
|
||||||
* download even when the settings default is enabled.
|
* download even when the settings default is enabled.
|
||||||
*/
|
*/
|
||||||
@@ -555,9 +555,9 @@ export interface PersistedQueueItem {
|
|||||||
/** Persisted user settings (electron-store). */
|
/** Persisted user settings (electron-store). */
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/** where video downloads are saved; empty string = the default Documents\Video folder */
|
/** 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 */
|
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
|
||||||
audioDir: string
|
audioFolder: string
|
||||||
defaultKind: MediaKind
|
defaultKind: MediaKind
|
||||||
defaultVideoQuality: string
|
defaultVideoQuality: string
|
||||||
defaultAudioQuality: string
|
defaultAudioQuality: string
|
||||||
@@ -570,7 +570,7 @@ export interface Settings {
|
|||||||
/** brand accent preset (buttons, links, selected nav item) */
|
/** brand accent preset (buttons, links, selected nav item) */
|
||||||
accentColor: AccentColor
|
accentColor: AccentColor
|
||||||
/** auto-detect video links from the clipboard on window focus */
|
/** auto-detect video links from the clipboard on window focus */
|
||||||
clipboardWatch: boolean
|
watchClipboard: boolean
|
||||||
/** default post-processing / format options for new downloads */
|
/** default post-processing / format options for new downloads */
|
||||||
downloadOptions: DownloadOptions
|
downloadOptions: DownloadOptions
|
||||||
/** yt-dlp --proxy value, e.g. 'socks5://127.0.0.1:1080'. Empty disables it. */
|
/** 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) */
|
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
|
||||||
restrictFilenames: boolean
|
restrictFilenames: boolean
|
||||||
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
|
/** 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 */
|
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
|
||||||
autoUpdateYtdlp: boolean
|
autoUpdateYtdlp: boolean
|
||||||
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
|
/** 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 */
|
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
|
||||||
ytdlpLastUpdateCheck: number
|
ytdlpLastUpdateCheck: number
|
||||||
/** apply a named custom-command template's extra args to every new download */
|
/** apply a named custom-command template's extra args to every new download */
|
||||||
customCommandEnabled: boolean
|
enableCustomCommands: boolean
|
||||||
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
|
/** id of the CommandTemplate applied when enableCustomCommands is on; null = none picked yet */
|
||||||
defaultTemplateId: string | null
|
defaultTemplateId: string | null
|
||||||
/** show a native OS notification when a download finishes or fails */
|
/** show a native OS notification when a download finishes or fails */
|
||||||
notifyOnComplete: boolean
|
notifyOnComplete: boolean
|
||||||
@@ -623,7 +623,7 @@ export interface Settings {
|
|||||||
/** launch AeroFetch automatically when the user signs in to Windows */
|
/** launch AeroFetch automatically when the user signs in to Windows */
|
||||||
launchAtStartup: boolean
|
launchAtStartup: boolean
|
||||||
/** whether the navigation sidebar is collapsed to icon-only mode */
|
/** 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
|
* 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
|
* 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
|
* 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.
|
* 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
|
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
|
||||||
* only where the release repo allows anonymous access). When the release repo
|
* only where the release repo allows anonymous access). When the release repo
|
||||||
@@ -652,8 +652,8 @@ export interface Settings {
|
|||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
||||||
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
||||||
videoDir: '',
|
videoFolder: '',
|
||||||
audioDir: '',
|
audioFolder: '',
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best',
|
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
|
// 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
|
// 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.
|
// (the onboarding tip points there). Existing installs keep their saved value.
|
||||||
clipboardWatch: false,
|
watchClipboard: false,
|
||||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
proxy: '',
|
proxy: '',
|
||||||
rateLimit: '',
|
rateLimit: '',
|
||||||
@@ -677,7 +677,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
youtubePlayerClient: '',
|
youtubePlayerClient: '',
|
||||||
youtubePoToken: '',
|
youtubePoToken: '',
|
||||||
restrictFilenames: false,
|
restrictFilenames: false,
|
||||||
downloadArchive: false,
|
useDownloadArchive: false,
|
||||||
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
|
// 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.
|
// configured channel so a stale binary can't silently cause YouTube 403s.
|
||||||
autoUpdateYtdlp: true,
|
autoUpdateYtdlp: true,
|
||||||
@@ -686,7 +686,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
// can switch to nightly in Settings → Software.
|
// can switch to nightly in Settings → Software.
|
||||||
ytdlpChannel: 'stable',
|
ytdlpChannel: 'stable',
|
||||||
ytdlpLastUpdateCheck: 0,
|
ytdlpLastUpdateCheck: 0,
|
||||||
customCommandEnabled: false,
|
enableCustomCommands: false,
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
notifyOnComplete: true,
|
notifyOnComplete: true,
|
||||||
// Default off so adding a watched channel doesn't silently fill the user's
|
// 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,
|
hasCompletedOnboarding: false,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
launchAtStartup: false,
|
launchAtStartup: false,
|
||||||
sidebarCollapsed: false,
|
isSidebarCollapsed: false,
|
||||||
hardwareAcceleration: false,
|
useHardwareAcceleration: false,
|
||||||
updateToken: ''
|
updateToken: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,7 +903,7 @@ export interface ScheduledSyncStatus {
|
|||||||
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
|
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
|
||||||
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
|
// 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 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).
|
// per-download extra args (extra args can run code via --exec — audit F2).
|
||||||
|
|
||||||
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
|
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
|
||||||
|
|||||||
@@ -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.
|
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
|
||||||
expect(
|
expect(
|
||||||
selectExtraArgs({
|
selectExtraArgs({
|
||||||
customCommandEnabled: false,
|
enableCustomCommands: false,
|
||||||
perDownloadExtraArgs: '--exec "calc.exe"',
|
perDownloadExtraArgs: '--exec "calc.exe"',
|
||||||
defaultTemplateId: 'danger',
|
defaultTemplateId: 'danger',
|
||||||
templates
|
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', () => {
|
it('returns [] when disabled even if a default template id is set', () => {
|
||||||
expect(
|
expect(
|
||||||
selectExtraArgs({
|
selectExtraArgs({
|
||||||
customCommandEnabled: false,
|
enableCustomCommands: false,
|
||||||
perDownloadExtraArgs: undefined,
|
perDownloadExtraArgs: undefined,
|
||||||
defaultTemplateId: 'thumb',
|
defaultTemplateId: 'thumb',
|
||||||
templates
|
templates
|
||||||
@@ -411,7 +411,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
|
|||||||
it('parses a per-download override when enabled', () => {
|
it('parses a per-download override when enabled', () => {
|
||||||
expect(
|
expect(
|
||||||
selectExtraArgs({
|
selectExtraArgs({
|
||||||
customCommandEnabled: true,
|
enableCustomCommands: true,
|
||||||
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
|
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
templates
|
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', () => {
|
it('an explicit empty override yields [] even with a default template set', () => {
|
||||||
expect(
|
expect(
|
||||||
selectExtraArgs({
|
selectExtraArgs({
|
||||||
customCommandEnabled: true,
|
enableCustomCommands: true,
|
||||||
perDownloadExtraArgs: '',
|
perDownloadExtraArgs: '',
|
||||||
defaultTemplateId: 'thumb',
|
defaultTemplateId: 'thumb',
|
||||||
templates
|
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', () => {
|
it('falls back to the default template when no per-download override is given', () => {
|
||||||
expect(
|
expect(
|
||||||
selectExtraArgs({
|
selectExtraArgs({
|
||||||
customCommandEnabled: true,
|
enableCustomCommands: true,
|
||||||
perDownloadExtraArgs: undefined,
|
perDownloadExtraArgs: undefined,
|
||||||
defaultTemplateId: 'thumb',
|
defaultTemplateId: 'thumb',
|
||||||
templates
|
templates
|
||||||
@@ -444,7 +444,7 @@ describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
|
|||||||
it('returns [] when the default template id matches nothing', () => {
|
it('returns [] when the default template id matches nothing', () => {
|
||||||
expect(
|
expect(
|
||||||
selectExtraArgs({
|
selectExtraArgs({
|
||||||
customCommandEnabled: true,
|
enableCustomCommands: true,
|
||||||
perDownloadExtraArgs: undefined,
|
perDownloadExtraArgs: undefined,
|
||||||
defaultTemplateId: 'missing',
|
defaultTemplateId: 'missing',
|
||||||
templates
|
templates
|
||||||
@@ -550,7 +550,7 @@ describe('format sorting (-S)', () => {
|
|||||||
|
|
||||||
describe('selectExtraArgs url-pattern matching', () => {
|
describe('selectExtraArgs url-pattern matching', () => {
|
||||||
const base = {
|
const base = {
|
||||||
customCommandEnabled: true,
|
enableCustomCommands: true,
|
||||||
perDownloadExtraArgs: undefined,
|
perDownloadExtraArgs: undefined,
|
||||||
defaultTemplateId: null as string | null
|
defaultTemplateId: null as string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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._-]+$/)
|
expect(base, 'restricted to [A-Za-z0-9._-]').toMatch(/^[A-Za-z0-9._-]+$/)
|
||||||
}, 240_000)
|
}, 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 archive = join(outDir, 'archive.txt')
|
||||||
const mk = (id: string): string[] =>
|
const mk = (id: string): string[] =>
|
||||||
buildArgs({
|
buildArgs({
|
||||||
|
|||||||
@@ -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$/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user