Merge Batch 24 — zod validation layer (CC9)
This commit is contained in:
+12
-4
@@ -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.
|
||||
`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.*
|
||||
- [ ] **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`
|
||||
(`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.
|
||||
*Deferred to 1.x (per the release gate — the CC consolidations are explicitly post-1.0): adopting a schema
|
||||
layer (`zod`) is a new runtime dependency plus a rewrite of `validation.ts` + `setSettings` + backup import.
|
||||
High-value but a sweeping change best done as its own reviewed PR, not unattended; standard recorded.*
|
||||
*Fixed (Batch 24): `zod` adopted; every Settings key is a schema in
|
||||
[settingsSchema.ts](src/main/settingsSchema.ts) whose parsed output is exactly what gets persisted —
|
||||
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
|
||||
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.
|
||||
|
||||
Generated
+11
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "aerofetch",
|
||||
"version": "0.5.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fluentui/react-components": "^9.74.1",
|
||||
@@ -15,6 +16,7 @@
|
||||
"electron-store": "^11.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -9412,6 +9414,15 @@
|
||||
"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": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"electron-store": "^11.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+19
-193
@@ -1,28 +1,10 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||
import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
|
||||
import { parseSettingsField } from './settingsSchema'
|
||||
import { sanitizeOptions } from './settingsOptions'
|
||||
import { logger } from './logger'
|
||||
import {
|
||||
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'
|
||||
import { ACCENT_COLORS, DEFAULT_SETTINGS, type Settings, type DownloadOptions } from '@shared/ipc'
|
||||
|
||||
// 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
|
||||
@@ -44,53 +26,9 @@ export function applyLaunchAtStartup(enabled: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
// sanitizeOptions moved to settingsOptions.ts (CC9) so the field schemas can
|
||||
// reuse it without an import cycle; re-imported above for the legacy-file
|
||||
// migration below.
|
||||
|
||||
// Constructed lazily — electron-store needs app paths. (`userData` resolves
|
||||
// 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)[]) {
|
||||
const value = partial[key]
|
||||
if (value === undefined) continue
|
||||
switch (key) {
|
||||
case 'theme':
|
||||
if (value === 'light' || value === 'dark' || value === 'system') s.set('theme', value)
|
||||
break
|
||||
case 'accentColor':
|
||||
if ((ACCENT_COLORS as readonly string[]).includes(value as string)) {
|
||||
s.set('accentColor', value as Settings['accentColor'])
|
||||
}
|
||||
break
|
||||
case 'defaultKind':
|
||||
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
|
||||
break
|
||||
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
|
||||
// One schema layer validates AND coerces every field (CC9) — the parsed
|
||||
// output is exactly what gets stored; a failed parse skips the key so the
|
||||
// stored value stays. Side effects that don't belong in a pure schema stay
|
||||
// here: credential fields are encrypted at rest (see encryptSecret; they're
|
||||
// stripped from backup exports via SECRET_KEYS), and launchAtStartup syncs
|
||||
// the OS run-at-sign-in entry.
|
||||
const parsed = parseSettingsField(key, value)
|
||||
if (!parsed.ok) continue
|
||||
if (SECRET_KEYS.includes(key as (typeof SECRET_KEYS)[number])) {
|
||||
s.set(key, encryptSecret(parsed.value as string) as Settings[typeof key])
|
||||
} else {
|
||||
s.set(key, parsed.value)
|
||||
}
|
||||
if (key === 'launchAtStartup') applyLaunchAtStartup(parsed.value as boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user