eb53de2ea5
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe (the bulk of its size). On first run they are downloaded from a pinned upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and unpacked with the OS tar.exe into the managed userData/bin dir -- the same place as the self-updating yt-dlp, so they survive app updates and portable re-extraction. A hard onboarding gate blocks the app until they are present, with a "locate existing ffmpeg" folder-picker fallback for offline machines and a Repair action in Settings > About. The updater's streaming/checksum/redirect/idle-timeout download loop is extracted to lib/verifiedDownload.streamVerifiedFile and shared by both the app-installer download and the ffmpeg fetch; the updater's public behaviour and error strings are unchanged (its boundary tests still pass). No new npm dependency: extraction uses the System32 bsdtar resolved by absolute path (audit F3). Note: this commit also carries pre-existing, in-progress aria2c/network and updater-token work that was already uncommitted in the working tree and is entangled with the above in shared files (updater.ts, ipc.ts, preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network files and aria2c.exe). It was not cleanly separable by path, so it is included here rather than split out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.9 KiB
TypeScript
133 lines
5.9 KiB
TypeScript
/**
|
|
* 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('collectionOutputTemplate: empty allowed; relative multi-segment ok; traversal rejected', () => {
|
|
expect(parseSettingsField('collectionOutputTemplate', '')).toEqual(ok(''))
|
|
expect(
|
|
parseSettingsField(
|
|
'collectionOutputTemplate',
|
|
' %(uploader)s/%(playlist)s/%(title)s.%(ext)s '
|
|
)
|
|
).toEqual(ok('%(uploader)s/%(playlist)s/%(title)s.%(ext)s'))
|
|
expect(parseSettingsField('collectionOutputTemplate', '../%(title)s.%(ext)s')).toEqual(fail)
|
|
expect(parseSettingsField('collectionOutputTemplate', 'C:\\abs\\%(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('sleepRequests/sleepInterval/maxSleepInterval: coerce, round, clamp to [0,300]', () => {
|
|
expect(parseSettingsField('sleepRequests', 5)).toEqual(ok(5))
|
|
expect(parseSettingsField('sleepRequests', -3)).toEqual(ok(0))
|
|
expect(parseSettingsField('sleepRequests', 999)).toEqual(ok(300))
|
|
expect(parseSettingsField('sleepInterval', '4')).toEqual(ok(4))
|
|
expect(parseSettingsField('maxSleepInterval', 7.6)).toEqual(ok(8))
|
|
})
|
|
|
|
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('youtubePoToken', 'po_token_value')).toEqual(ok('po_token_value'))
|
|
expect(parseSettingsField('proxy', 42)).toEqual(fail)
|
|
})
|
|
|
|
it('unknown keys are skipped', () => {
|
|
expect(parseSettingsField('nope' as never, true)).toEqual(fail)
|
|
})
|
|
})
|