/** * 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 per-field because outputs differ. */ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType } = { 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( key: K, value: unknown ): { ok: true; value: Settings[K] } | { ok: false } { const schema = SETTINGS_FIELD_SCHEMAS[key] as z.ZodType | undefined if (!schema) return { ok: false } const parsed = schema.safeParse(value) return parsed.success ? { ok: true, value: parsed.data } : { ok: false } }