Files
AeroFetch/src/main/settings.ts
T
debont80 5a9f2de390 Encrypt credentials at rest; unify clipboard-link suggestions
Encrypt proxy / youtubePoToken / updateToken on disk via safeStorage (DPAPI on Windows), with a one-time launch migration for legacy plaintext. Decrypted before reaching callers/renderer; backup export still writes clear, as documented. Harden updater token handling to refuse cross-origin redirects on the authenticated REST check.

Extract the clipboard-link logic from DownloadBar into the shared useClipboardLink hook: add an optional filter (library skips single-video links), an offer() for external aerofetch:// / .url links, and a source field driving banner wording. Add looksLikeSingleVideo plus its unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:16:16 -04:00

366 lines
14 KiB
TypeScript

import { app, safeStorage } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS,
isYtdlpUpdateChannel,
type Settings,
type DownloadOptions,
type SponsorBlockCategory
} from '@shared/ipc'
const DEFAULTS: Settings = {
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoDir: '',
audioDir: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// nightly channel (fastest to follow YouTube changes that cause 403s).
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
updateToken: ''
}
/**
* 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 */
}
}
/** Fixed path for the --download-archive file; not user-configurable. */
export function getDownloadArchivePath(): string {
return join(app.getPath('userData'), 'download-archive.txt')
}
/**
* The default per-kind download destination: video → Documents\Video,
* audio → Documents\Audio. Used when the user hasn't set an explicit output
* folder (Settings → Download folder), so downloads are sorted by type.
*/
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
}
/**
* Create the Documents\Video and Documents\Audio folders up front (called once
* at startup) so they exist the moment the app opens, not just after the first
* download. Best-effort: yt-dlp also creates the output dir at download time, so
* a failure here (read-only Documents, redirected folder) is non-fatal.
*/
export function ensureMediaDirs(): void {
for (const kind of ['video', 'audio'] as const) {
try {
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
} catch {
/* non-fatal — the download path will be created on demand instead */
}
}
}
// 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.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,
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),
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
}
// --- 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 still decrypted before
// reaching the renderer and exported in clear by backup — this guards the file at
// rest only.
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 */
}
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))
}
}
export function getSettings(): Settings {
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)
}
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
return withDecryptedSecrets(s.store)
}
/** 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()
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':
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':
case 'defaultAudioQuality':
case 'rateLimit':
case 'youtubePlayerClient':
if (typeof value === 'string') s.set(key, value)
break
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
// may embed user:pass@host; youtubePoToken is an access token. exportBackup
// still writes them in clear (via the decrypted getSettings), as documented.
case 'proxy':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, encryptSecret(value))
break
case 'updateToken':
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
break
}
}
return getSettings()
}