Files
AeroFetch/src/main/settingsSchema.ts
T
debont80 42b7b2e01f feat(roadmap): surface stale checkboxes + collection output-path setting
Command preview + incognito toggle were already wired end-to-end in the
DownloadBar (Show command button, Incognito checkbox) — flipped the two
stale ROADMAP.md checkboxes with notes; verified live in the preview.

Output-path setting (PINCHFLAT): new Settings.collectionOutputTemplate — a
raw yt-dlp -o template overriding the default Channel/Playlist/NNN-Title
layout for Library downloads (empty = default). collectionOutputTemplate()
takes an optional custom template (joined under outDir, left for yt-dlp to
expand); download.ts passes the setting; zod schema validates it as a safe
relative template; Settings -> Filenames field. Tests in buildArgs +
settingsSchema; live-verified the field renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:44 -04:00

120 lines
5.2 KiB
TypeScript

/**
* The one validate-AND-coerce layer for Settings writes (CC9). Every key that
* setSettings accepts is described here as a zod schema whose output is exactly
* what gets persisted — replacing the bespoke per-key switch. The schemas reuse
* validation.ts's domain predicates (`isValidSyncTime`, `isSafeOutputDir`,
* `isSafeFilenameTemplate`) as refinements, so the rules live in one place.
*
* Semantics are a faithful port of the old switch (unit-tested in
* test/settingsSchema.test.ts): invalid values fail parse and are SKIPPED by
* applySettings (the stored value stays), numbers coerce + clamp, strings trim
* where they trimmed before. Side effects (secret encryption, launch-at-startup
* registration) stay in settings.ts — this layer is pure.
*
* The jsonStore row guards (`isValid*` in validation.ts) intentionally stay
* type-guard predicates: they filter untrusted disk rows in a hot read path and
* need no coercion.
*/
import { z } from 'zod'
import {
ACCENT_COLORS,
COOKIE_BROWSERS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
YTDLP_UPDATE_CHANNELS,
isValidSyncTime,
type Settings
} from '@shared/ipc'
import { isSafeOutputDir, isSafeFilenameTemplate } from './core/validation'
import { sanitizeOptions } from './settingsOptions'
/** Coerce to a finite number, round, and clamp into [min, max] (maxConcurrent, aria2c). */
const clampedInt = (min: number, max: number) =>
z.coerce
.number()
.refine((n) => Number.isFinite(n))
.transform((n) => Math.min(max, Math.max(min, Math.round(n))))
/** Trimmed string that must satisfy the given predicate after trimming. */
const trimmedWhere = (ok: (v: string) => boolean) =>
z
.string()
.transform((v) => v.trim())
.refine(ok)
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
const RATE_LIMIT_RE = /^(\d+\.?\d*[KMGkmg]?B?)?$/
const bool = z.boolean()
/**
* One schema per Settings key; `applySettings` looks the key up, `safeParse`s
* the incoming value, and stores the parsed output — or skips the key entirely
* on failure. Typed as ZodType<unknown> per-field because outputs differ.
*/
export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings[K], unknown> } = {
theme: z.enum(['light', 'dark', 'system']),
accentColor: z.enum(ACCENT_COLORS),
defaultKind: z.enum(['video', 'audio']),
maxConcurrent: clampedInt(1, 5),
// aria2c caps -x/-s at 16; clamp like maxConcurrent rather than reject (L64).
aria2cConnections: clampedInt(1, 16),
// Reaches `schtasks /ST` when the daily sync is (re)registered (L51).
syncTime: z.string().refine(isValidSyncTime),
watchClipboard: bool,
useAria2c: bool,
restrictFilenames: bool,
useDownloadArchive: bool,
autoUpdateYtdlp: bool,
enableCustomCommands: bool,
notifyOnComplete: bool,
autoDownloadNew: bool,
hasCompletedOnboarding: bool,
minimizeToTray: bool,
isSidebarCollapsed: bool,
useHardwareAcceleration: bool,
launchAtStartup: bool,
// Same allowlist that guards the `--update-to` flag (audit F1).
ytdlpChannel: z.enum(YTDLP_UPDATE_CHANNELS),
// Set by the main-process auto-updater; a bogus value at worst skips one check.
ytdlpLastUpdateCheck: z.coerce.number().refine((n) => Number.isFinite(n) && n >= 0),
defaultTemplateId: z.union([z.null(), z.string()]),
cookieSource: z.enum(['none', 'browser', 'login']),
cookiesBrowser: z.enum(COOKIE_BROWSERS),
// Always store a fully-validated object; the renderer sends the whole group.
downloadOptions: z.unknown().transform((v) => sanitizeOptions(v)),
// An empty string is allowed — it clears the override and restores the default.
videoFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
audioFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
filenameTemplate: trimmedWhere(isSafeFilenameTemplate),
// Collection folder layout (PINCHFLAT): empty = built-in default; otherwise a
// safe relative yt-dlp template (isSafeFilenameTemplate allows '/'-separated
// path segments and rejects absolute paths + '..' traversal).
collectionOutputTemplate: trimmedWhere((v) => v === '' || isSafeFilenameTemplate(v)),
defaultVideoQuality: z.enum(VIDEO_QUALITY_OPTIONS),
defaultAudioQuality: z.enum(AUDIO_QUALITY_OPTIONS),
rateLimit: trimmedWhere((v) => RATE_LIMIT_RE.test(v)),
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
youtubePlayerClient: z.string().transform((v) => v.trim()),
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
proxy: z.string(),
youtubePoToken: z.string(),
// An access token has no spaces; trim before encrypting (like proxy creds).
updateToken: z.string().transform((v) => v.trim())
}
/**
* Parse one incoming settings value. Returns `{ ok: true, value }` with the
* coerced/clamped/trimmed value to store, or `{ ok: false }` when the write
* must be skipped (unknown key or failed validation).
*/
export function parseSettingsField<K extends keyof Settings>(
key: K,
value: unknown
): { ok: true; value: Settings[K] } | { ok: false } {
const schema = SETTINGS_FIELD_SCHEMAS[key] as z.ZodType<Settings[K], unknown> | undefined
if (!schema) return { ok: false }
const parsed = schema.safeParse(value)
return parsed.success ? { ok: true, value: parsed.data } : { ok: false }
}