Merge Batch 24 — zod validation layer (CC9)

This commit is contained in:
2026-07-02 12:41:01 -04:00
7 changed files with 330 additions and 197 deletions
+12 -4
View File
@@ -2018,13 +2018,21 @@ split — but that style is unenforced and several "do the same thing two ways"
renderer's `logError` (M29) failures to the same file sink — closing the "sink" half M29/R6 deferred here. renderer's `logError` (M29) failures to the same file sink — closing the "sink" half M29/R6 deferred here.
`errorlog.ts` stays as user-facing download-failure data. The user-facing **toast** half ties to the `errorlog.ts` stays as user-facing download-failure data. The user-facing **toast** half ties to the
global status surface (UI25/UX9) and lands there.* global status surface (UI25/UX9) and lands there.*
- [ ] **CC9 — Four validation styles.** Type-guard predicates (`isValid*` in validation.ts), - [x] **CC9 — Four validation styles.** Type-guard predicates (`isValid*` in validation.ts),
coerce-with-fallback (`sanitizeOptions`, `templates.sanitize`), an imperative per-key `switch` coerce-with-fallback (`sanitizeOptions`, `templates.sanitize`), an imperative per-key `switch`
(`setSettings`, M24), and `throw` (`assertHttpUrl`). **Standard:** one schema layer (e.g. `zod`) that (`setSettings`, M24), and `throw` (`assertHttpUrl`). **Standard:** one schema layer (e.g. `zod`) that
both *validates and coerces*; `setSettings` runs values through the same schema instead of a bespoke switch. both *validates and coerces*; `setSettings` runs values through the same schema instead of a bespoke switch.
*Deferred to 1.x (per the release gate — the CC consolidations are explicitly post-1.0): adopting a schema *Fixed (Batch 24): `zod` adopted; every Settings key is a schema in
layer (`zod`) is a new runtime dependency plus a rewrite of `validation.ts` + `setSettings` + backup import. [settingsSchema.ts](src/main/settingsSchema.ts) whose parsed output is exactly what gets persisted —
High-value but a sweeping change best done as its own reviewed PR, not unattended; standard recorded.* numbers coerce+clamp, strings trim, enums allowlist, folders/templates run the shared validation.ts
refinements, `downloadOptions` sanitizes (via the extracted
[settingsOptions.ts](src/main/settingsOptions.ts)). `setSettings`'s 130-line switch is now a 15-line loop:
parse → skip-on-fail → store, with the two genuine side effects (secret encryption at rest, the
launch-at-startup registry sync) kept beside their module state. Backup import flows through the same
schema (it calls setSettings). Parity with the old switch is pinned field-by-field in
[test/settingsSchema.test.ts](test/settingsSchema.test.ts); the whole suite passes unchanged. The jsonStore
row guards deliberately stay type-guard predicates (hot read path filtering untrusted disk rows — no
coercion wanted), documented in the schema header.*
- [ ] **CC10 — Mixed serialization/persistence.** `electron-store` (settings) **and** hand-rolled - [ ] **CC10 — Mixed serialization/persistence.** `electron-store` (settings) **and** hand-rolled
pretty-JSON files (history/errorlog/templates/sources/media-items, M1) for the same job, plus pretty-JSON files (history/errorlog/templates/sources/media-items, M1) for the same job, plus
yt-dlp-mandated formats (Netscape cookies, plaintext archive) and base64 `enc:v1:` secrets. yt-dlp-mandated formats (Netscape cookies, plaintext archive) and base64 `enc:v1:` secrets.
+11
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "aerofetch", "name": "aerofetch",
"version": "0.5.0", "version": "0.5.0",
"license": "UNLICENSED",
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1", "@fluentui/react-components": "^9.74.1",
@@ -15,6 +16,7 @@
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
@@ -9412,6 +9414,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.14", "version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
+1
View File
@@ -40,6 +40,7 @@
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
+19 -193
View File
@@ -1,28 +1,10 @@
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 { RENAMED_SETTINGS_KEYS } from './settingsMigration' import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
import { parseSettingsField } from './settingsSchema'
import { sanitizeOptions } from './settingsOptions'
import { logger } from './logger' import { logger } from './logger'
import { import { ACCENT_COLORS, DEFAULT_SETTINGS, type Settings, type DownloadOptions } from '@shared/ipc'
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
DEFAULT_DOWNLOAD_OPTIONS,
DEFAULT_SETTINGS,
isYtdlpUpdateChannel,
isValidSyncTime,
type Settings,
type DownloadOptions,
type SponsorBlockCategory,
type AudioFormat,
type VideoContainer,
type VideoCodecPref
} from '@shared/ipc'
// The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared // The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared
// contract — single-sourced so the renderer FALLBACK and the preview mock can't // contract — single-sourced so the renderer FALLBACK and the preview mock can't
@@ -44,53 +26,9 @@ export function applyLaunchAtStartup(enabled: boolean): void {
} }
} }
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the // sanitizeOptions moved to settingsOptions.ts (CC9) so the field schemas can
// defaults for any missing/invalid field. Used both to migrate older settings // reuse it without an import cycle; re-imported above for the legacy-file
// files (which predate downloadOptions) and to validate renderer writes. // migration below.
function sanitizeOptions(input: unknown): DownloadOptions {
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
const d = DEFAULT_DOWNLOAD_OPTIONS
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
const cats = Array.isArray(o.sponsorBlockCategories)
? (o.sponsorBlockCategories.filter((c) =>
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
) as SponsorBlockCategory[])
: d.sponsorBlockCategories
return {
audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string)
? (o.audioFormat as AudioFormat)
: d.audioFormat,
videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string)
? (o.videoContainer as VideoContainer)
: d.videoContainer,
preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes(
o.preferredVideoCodec as string
)
? (o.preferredVideoCodec as VideoCodecPref)
: d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
? o.subtitleLanguages.trim()
: d.subtitleLanguages,
autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles),
sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock),
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined,
metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined,
metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined,
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
}
}
// Constructed lazily — electron-store needs app paths. (`userData` resolves // Constructed lazily — electron-store needs app paths. (`userData` resolves
// pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.) // pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.)
@@ -268,131 +206,19 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
for (const key of Object.keys(partial) as (keyof Settings)[]) { for (const key of Object.keys(partial) as (keyof Settings)[]) {
const value = partial[key] const value = partial[key]
if (value === undefined) continue if (value === undefined) continue
switch (key) { // One schema layer validates AND coerces every field (CC9) — the parsed
case 'theme': // output is exactly what gets stored; a failed parse skips the key so the
if (value === 'light' || value === 'dark' || value === 'system') s.set('theme', value) // stored value stays. Side effects that don't belong in a pure schema stay
break // here: credential fields are encrypted at rest (see encryptSecret; they're
case 'accentColor': // stripped from backup exports via SECRET_KEYS), and launchAtStartup syncs
if ((ACCENT_COLORS as readonly string[]).includes(value as string)) { // the OS run-at-sign-in entry.
s.set('accentColor', value as Settings['accentColor']) const parsed = parseSettingsField(key, value)
} if (!parsed.ok) continue
break if (SECRET_KEYS.includes(key as (typeof SECRET_KEYS)[number])) {
case 'defaultKind': s.set(key, encryptSecret(parsed.value as string) as Settings[typeof key])
if (value === 'video' || value === 'audio') s.set('defaultKind', value) } else {
break s.set(key, parsed.value)
case 'maxConcurrent': {
const n = Number(value)
if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n))))
break
}
case 'aria2cConnections': {
// aria2c caps -x/-s at 16; clamp like maxConcurrent rather than reject (L64).
const n = Number(value)
if (Number.isFinite(n)) s.set('aria2cConnections', Math.min(16, Math.max(1, Math.round(n))))
break
}
case 'syncTime':
// Reaches `schtasks /ST` when the daily sync is (re)registered — only a
// well-formed 24h HH:MM is ever stored (L51).
if (isValidSyncTime(value)) s.set('syncTime', value)
break
case 'watchClipboard':
case 'useAria2c':
case 'restrictFilenames':
case 'useDownloadArchive':
case 'autoUpdateYtdlp':
case 'enableCustomCommands':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
case 'minimizeToTray':
case 'isSidebarCollapsed':
case 'useHardwareAcceleration':
if (typeof value === 'boolean') s.set(key, value)
break
case 'launchAtStartup':
if (typeof value === 'boolean') {
s.set('launchAtStartup', value)
applyLaunchAtStartup(value)
}
break
case 'ytdlpChannel':
// Same allowlist that guards the `--update-to` flag (audit F1).
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
break
case 'ytdlpLastUpdateCheck': {
// Set by the main-process auto-updater; validated here since setSettings
// is the only writer. A bogus value at worst skips/forces one check.
const n = Number(value)
if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n)
break
}
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break
case 'cookieSource':
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
break
case 'cookiesBrowser':
if ((COOKIE_BROWSERS as readonly string[]).includes(value as string)) {
s.set(key, value as Settings['cookiesBrowser'])
}
break
case 'downloadOptions':
// Always store a fully-validated object; the renderer sends the whole
// group (it merges field changes locally before calling setSettings).
s.set('downloadOptions', sanitizeOptions(value))
break
case 'videoFolder':
case 'audioFolder':
// An empty string is allowed — it clears the override and restores the
// Documents\Video / Documents\Audio default for that kind.
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
s.set(key, value.trim())
}
break
case 'filenameTemplate':
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
s.set('filenameTemplate', value.trim())
}
break
case 'defaultVideoQuality':
if (
typeof value === 'string' &&
(VIDEO_QUALITY_OPTIONS as readonly string[]).includes(value)
) {
s.set('defaultVideoQuality', value)
}
break
case 'defaultAudioQuality':
if (
typeof value === 'string' &&
(AUDIO_QUALITY_OPTIONS as readonly string[]).includes(value)
) {
s.set('defaultAudioQuality', value)
}
break
case 'rateLimit':
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
if (typeof value === 'string' && /^(\d+\.?\d*[KMGkmg]?B?)?$/.test(value.trim())) {
s.set('rateLimit', value.trim())
}
break
case 'youtubePlayerClient':
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
if (typeof value === 'string') s.set('youtubePlayerClient', value.trim())
break
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
// may embed user:pass@host; youtubePoToken is an access token. Both are
// stripped from backup exports (see SECRET_KEYS / backup.ts).
case 'proxy':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, encryptSecret(value))
break
case 'updateToken':
// An access token has no spaces; trim before encrypting (like proxy creds).
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
break
} }
if (key === 'launchAtStartup') applyLaunchAtStartup(parsed.value as boolean)
} }
} }
+62
View File
@@ -0,0 +1,62 @@
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
type SponsorBlockCategory,
type AudioFormat,
type VideoContainer,
type VideoCodecPref
} from '@shared/ipc'
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
// defaults for any missing/invalid field. Used both to migrate older settings
// files (which predate downloadOptions) and to validate renderer writes. Lives
// in its own module (not settings.ts) so the settings field schemas (CC9) can
// reuse it without an import cycle.
export function sanitizeOptions(input: unknown): DownloadOptions {
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
const d = DEFAULT_DOWNLOAD_OPTIONS
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
const cats = Array.isArray(o.sponsorBlockCategories)
? (o.sponsorBlockCategories.filter((c) =>
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
) as SponsorBlockCategory[])
: d.sponsorBlockCategories
return {
audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string)
? (o.audioFormat as AudioFormat)
: d.audioFormat,
videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string)
? (o.videoContainer as VideoContainer)
: d.videoContainer,
preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes(
o.preferredVideoCodec as string
)
? (o.preferredVideoCodec as VideoCodecPref)
: d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
? o.subtitleLanguages.trim()
: d.subtitleLanguages,
autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles),
sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock),
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined,
metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined,
metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined,
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 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 './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),
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 }
}
+110
View File
@@ -0,0 +1,110 @@
/**
* CC9: the zod field schemas must be a faithful port of the old setSettings
* switch same accepts, same rejects, same coercions (clamp/trim/sanitize).
*/
import { describe, it, expect } from 'vitest'
import { parseSettingsField } from '../src/main/settingsSchema'
import { DEFAULT_DOWNLOAD_OPTIONS } from '@shared/ipc'
const ok = <T>(v: T): { ok: true; value: T } => ({ ok: true, value: v })
const fail = { ok: false }
describe('parseSettingsField (CC9 schema parity)', () => {
it('theme: enum only', () => {
expect(parseSettingsField('theme', 'dark')).toEqual(ok('dark'))
expect(parseSettingsField('theme', 'blue')).toEqual(fail)
expect(parseSettingsField('theme', 1)).toEqual(fail)
})
it('maxConcurrent: coerces, rounds, clamps to [1,5]', () => {
expect(parseSettingsField('maxConcurrent', 3)).toEqual(ok(3))
expect(parseSettingsField('maxConcurrent', '4')).toEqual(ok(4))
expect(parseSettingsField('maxConcurrent', 99)).toEqual(ok(5))
expect(parseSettingsField('maxConcurrent', -2)).toEqual(ok(1))
expect(parseSettingsField('maxConcurrent', 2.6)).toEqual(ok(3))
expect(parseSettingsField('maxConcurrent', 'abc')).toEqual(fail)
})
it('aria2cConnections: clamps to [1,16] (L64)', () => {
expect(parseSettingsField('aria2cConnections', 64)).toEqual(ok(16))
expect(parseSettingsField('aria2cConnections', 0)).toEqual(ok(1))
})
it('syncTime: 24h HH:MM only (L51)', () => {
expect(parseSettingsField('syncTime', '09:30')).toEqual(ok('09:30'))
expect(parseSettingsField('syncTime', '9:30')).toEqual(fail)
expect(parseSettingsField('syncTime', '24:00')).toEqual(fail)
})
it('booleans: strict', () => {
expect(parseSettingsField('useAria2c', true)).toEqual(ok(true))
expect(parseSettingsField('useAria2c', 'true')).toEqual(fail)
expect(parseSettingsField('enableCustomCommands', false)).toEqual(ok(false))
})
it('ytdlpChannel: allowlist (F1)', () => {
expect(parseSettingsField('ytdlpChannel', 'nightly')).toEqual(ok('nightly'))
expect(parseSettingsField('ytdlpChannel', 'master')).toEqual(fail)
})
it('ytdlpLastUpdateCheck: non-negative finite number, coerced', () => {
expect(parseSettingsField('ytdlpLastUpdateCheck', 123)).toEqual(ok(123))
expect(parseSettingsField('ytdlpLastUpdateCheck', -1)).toEqual(fail)
expect(parseSettingsField('ytdlpLastUpdateCheck', 'NaN')).toEqual(fail)
})
it('defaultTemplateId: string or null', () => {
expect(parseSettingsField('defaultTemplateId', null)).toEqual(ok(null))
expect(parseSettingsField('defaultTemplateId', 't1')).toEqual(ok('t1'))
expect(parseSettingsField('defaultTemplateId', 7)).toEqual(fail)
})
it('downloadOptions: always sanitizes to a full valid object', () => {
const r = parseSettingsField('downloadOptions', { embedSubtitles: true, audioFormat: 'nope' })
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.value.embedSubtitles).toBe(true)
expect(r.value.audioFormat).toBe(DEFAULT_DOWNLOAD_OPTIONS.audioFormat)
expect(Object.keys(r.value).length).toBeGreaterThanOrEqual(
Object.keys(DEFAULT_DOWNLOAD_OPTIONS).length - 3 // optional metadata fields may be undefined
)
}
})
it('folders: trims; empty clears; relative paths rejected (isSafeOutputDir)', () => {
expect(parseSettingsField('videoFolder', ' D:\\Media ')).toEqual(ok('D:\\Media'))
expect(parseSettingsField('videoFolder', '')).toEqual(ok(''))
expect(parseSettingsField('audioFolder', 'Downloads')).toEqual(fail)
expect(parseSettingsField('audioFolder', '..\\elsewhere')).toEqual(fail)
})
it('filenameTemplate: trimmed and safety-checked', () => {
expect(parseSettingsField('filenameTemplate', ' %(title)s.%(ext)s ')).toEqual(
ok('%(title)s.%(ext)s')
)
expect(parseSettingsField('filenameTemplate', '../%(title)s.%(ext)s')).toEqual(fail)
})
it('rateLimit: yt-dlp format or empty', () => {
expect(parseSettingsField('rateLimit', '2.5M')).toEqual(ok('2.5M'))
expect(parseSettingsField('rateLimit', ' 500K ')).toEqual(ok('500K'))
expect(parseSettingsField('rateLimit', '')).toEqual(ok(''))
expect(parseSettingsField('rateLimit', 'fast')).toEqual(fail)
})
it('quality presets: allowlisted', () => {
expect(parseSettingsField('defaultVideoQuality', '720p')).toEqual(ok('720p'))
expect(parseSettingsField('defaultVideoQuality', '4K')).toEqual(fail)
expect(parseSettingsField('defaultAudioQuality', 'Best')).toEqual(ok('Best'))
})
it('secrets: plain strings pass through (encryption happens in settings.ts)', () => {
expect(parseSettingsField('proxy', 'socks5://u:p@h:1')).toEqual(ok('socks5://u:p@h:1'))
expect(parseSettingsField('updateToken', ' tok ')).toEqual(ok('tok'))
expect(parseSettingsField('proxy', 42)).toEqual(fail)
})
it('unknown keys are skipped', () => {
expect(parseSettingsField('nope' as never, true)).toEqual(fail)
})
})