import { app } from 'electron' import { join } from 'path' import Store from 'electron-store' import { AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_CODECS, SPONSORBLOCK_CATEGORIES, COOKIE_BROWSERS, DEFAULT_DOWNLOAD_OPTIONS, type Settings, type DownloadOptions, type SponsorBlockCategory } from '@shared/ipc' const DEFAULTS: Settings = { outputDir: '', // resolved to the OS Downloads folder on first read defaultKind: 'video', defaultVideoQuality: 'Best available', defaultAudioQuality: 'Best (MP3)', maxConcurrent: 2, filenameTemplate: '%(title)s.%(ext)s', theme: 'light', clipboardWatch: true, downloadOptions: DEFAULT_DOWNLOAD_OPTIONS, proxy: '', rateLimit: '', useAria2c: false, cookieSource: 'none', cookiesBrowser: 'chrome', restrictFilenames: false, downloadArchive: false } /** Fixed path for the --download-archive file; not user-configurable. */ export function getDownloadArchivePath(): string { return join(app.getPath('userData'), 'download-archive.txt') } // 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 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.includes(o.audioFormat as never) ? o.audioFormat! : d.audioFormat, videoContainer: VIDEO_CONTAINERS.includes(o.videoContainer as never) ? o.videoContainer! : d.videoContainer, preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never) ? o.preferredVideoCodec! : d.preferredVideoCodec, 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), embedMetadata: bool(o.embedMetadata, d.embedMetadata), embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail) } } // Constructed lazily — electron-store needs app paths, which exist only after // the app is ready (all callers run post-ready). let store: Store | null = null function getStore(): Store { if (!store) store = new Store({ name: 'settings', defaults: DEFAULTS }) return store } export function getSettings(): Settings { const s = getStore() // Fill in the real Downloads path the first time, and persist it. if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads')) // Migrate settings files that predate downloadOptions (or hold a partial one). s.set('downloadOptions', sanitizeOptions(s.get('downloadOptions'))) return s.store } // Validate each key before persisting — the renderer is the only caller today, // but settings flow into process spawning (maxConcurrent) and the native window // background (theme), so an out-of-range or malformed value shouldn't get stored. export function setSettings(partial: Partial): Settings { const s = getStore() 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') s.set('theme', value) 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 'clipboardWatch': case 'useAria2c': case 'restrictFilenames': case 'downloadArchive': if (typeof value === 'boolean') s.set(key, 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 'outputDir': case 'defaultVideoQuality': case 'defaultAudioQuality': case 'filenameTemplate': case 'proxy': case 'rateLimit': if (typeof value === 'string') s.set(key, value) break } } return getSettings() }