import { app, safeStorage } from 'electron' import Store from 'electron-store' import { isSafeFilenameTemplate, isSafeOutputDir } from './validation' 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' // 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 // drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives // alongside DEFAULT_SETTINGS in shared/ipc.ts. const DEFAULTS: Settings = DEFAULT_SETTINGS /** * Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at * startup and whenever the toggle changes. On Windows this writes/removes a * per-user registry Run entry — no admin needed, matching the app's no-elevation * stance. Best-effort: a failure here just means the toggle didn't take effect. */ export function applyLaunchAtStartup(enabled: boolean): void { try { app.setLoginItemSettings({ openAtLogin: enabled }) } catch { /* non-fatal — e.g. unsupported platform */ } } // 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 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, 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 } // Decrypted-settings cache — avoids repeated DPAPI calls on hot paths such as // buildCommand, the maxConcurrent check, completion notify, and the system-theme // bridge (PERF1). Invalidated by every setSettings write and by the one-time // migrateSecretsAtRest so callers always see the current values. let cachedSettings: Settings | null = null // --- Credential encryption at rest ------------------------------------------ // proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API // tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows) // so a leaked settings.json doesn't expose them. They're decrypted before // reaching the renderer; backup export strips them entirely (see backup.ts, M22). // Exported so backup.ts shares this one list rather than keeping a parallel copy. export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const // Marks a value produced by encryptSecret, so a read can tell ciphertext from a // legacy plaintext value (written before encryption existed, or while safeStorage // was unavailable) and migrate it on the next write. const ENC_PREFIX = 'enc:v1:' /** Encrypt a secret for storage. Falls back to plaintext where safeStorage is unavailable. */ function encryptSecret(plain: string): string { if (!plain) return '' try { if (safeStorage.isEncryptionAvailable()) { return ENC_PREFIX + safeStorage.encryptString(plain).toString('base64') } } catch { /* fall through — store plaintext, as it was before encryption existed */ } // R7: OS encryption (DPAPI/keychain) is unavailable, so this credential is about // to be written in cleartext. Warn rather than fall back silently — a leaked // settings.json would then expose the proxy password / API token. (DPAPI is // effectively always present on Windows, so this should never fire in practice.) logger.warn('OS secret encryption unavailable — storing credential as plaintext') return plain } /** Decrypt a stored secret. Legacy plaintext is returned as-is; an undecryptable blob → ''. */ function decryptSecret(stored: string): string { if (!stored.startsWith(ENC_PREFIX)) return stored try { return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64')) } catch { // Different user/machine or a corrupt blob — drop it rather than surface // ciphertext into the UI or onto a yt-dlp command line. return '' } } /** A copy of settings with the credential fields decrypted for in-process use. */ function withDecryptedSecrets(raw: Settings): Settings { const out = { ...raw } for (const key of SECRET_KEYS) out[key] = decryptSecret(raw[key] ?? '') return out } /** * One-time (per launch) migration: re-store any credential still held as legacy * plaintext as ciphertext. Lets settings files written before at-rest encryption * get protected without a write on the hot getSettings() path. No-op when there's * nothing to migrate or safeStorage is unavailable. */ export function migrateSecretsAtRest(): void { let available = false try { available = safeStorage.isEncryptionAvailable() } catch { return } if (!available) return const s = getStore() for (const key of SECRET_KEYS) { const raw = s.get(key) ?? '' if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw)) } cachedSettings = null } export function getSettings(): Settings { if (cachedSettings) return cachedSettings const s = getStore() // getSettings() is on hot paths (buildCommand, notification checks, the system- // theme bridge, several IPC handlers). electron-store writes to disk on every // `set`, so only write when something actually changed — otherwise this churns // the settings file on every read. (audit P1) const cur = s.store // videoDir/audioDir are intentionally left blank by default — an empty value // routes that kind into Documents\Video / Documents\Audio (see buildCommand). // Only an explicit user choice (Settings → folders) overrides that. // Migrate settings files that predate downloadOptions (or hold a partial one), // but only persist when sanitizing actually altered the stored value. const sanitized = sanitizeOptions(cur.downloadOptions) if (!downloadOptionsEqual(sanitized, cur.downloadOptions)) { s.set('downloadOptions', sanitized) } // Coerce an accentColor left over from a renamed/removed preset (e.g. an old // 'toffee' or 'indigo' default) onto the current default, so it resolves cleanly. if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) { s.set('accentColor', DEFAULTS.accentColor) } // Migrate the legacy audio-quality label 'Best (MP3)' to the format-agnostic // 'Best' so the dropdown matches and the label no longer names a format (M18). if (cur.defaultAudioQuality === 'Best (MP3)') { s.set('defaultAudioQuality', 'Best') } // Hand callers (and, via IPC, the renderer) plaintext credentials — they're // only encrypted on disk (see withDecryptedSecrets / encryptSecret). cachedSettings = withDecryptedSecrets(s.store) return cachedSettings } /** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */ function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean { if (!b || typeof b !== 'object') return false const o = b as Partial const keys = Object.keys(a) as (keyof DownloadOptions)[] for (const k of keys) { if (k === 'sponsorBlockCategories') { const av = a[k] const bv = o[k] if (!Array.isArray(bv) || av.length !== bv.length) return false for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false } else if (a[k] !== o[k]) { return false } } return true } // 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() try { applySettings(s, partial) } catch (e) { // R5: a write failure (disk full, read-only profile) must not crash the IPC // handler or surface as an unhandled rejection. Log it; getSettings() below // returns the store's ACTUAL persisted state, so the renderer's reconciliation // (M34) reflects what truly saved instead of the optimistic value. logger.error('settings write failed', e) } cachedSettings = null return getSettings() } function applySettings(s: Store, partial: Partial): 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 'clipboardWatch': case 'useAria2c': case 'restrictFilenames': case 'downloadArchive': case 'autoUpdateYtdlp': case 'customCommandEnabled': case 'notifyOnComplete': case 'autoDownloadNew': case 'hasCompletedOnboarding': case 'minimizeToTray': case 'sidebarCollapsed': 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 'videoDir': case 'audioDir': // 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 } } }