42b7b2e01f
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>
120 lines
5.2 KiB
TypeScript
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 }
|
|
}
|