36699531cf
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
import { app, safeStorage } from 'electron'
|
|
import Store from 'electron-store'
|
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
|
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,
|
|
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<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, which exist only after
|
|
// the app is ready (all callers run post-ready).
|
|
let store: Store<Settings> | null = null
|
|
function getStore(): Store<Settings> {
|
|
if (!store) store = new Store<Settings>({ 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.)
|
|
console.warn('[AeroFetch] 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<DownloadOptions>
|
|
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>): 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.
|
|
console.error('[AeroFetch] settings write failed:', e)
|
|
}
|
|
cachedSettings = null
|
|
return getSettings()
|
|
}
|
|
|
|
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 '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
|
|
}
|
|
}
|
|
}
|