Harden audit findings: correctness, type-safety, Windows conventions & polish

Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified —
typecheck + 234 tests + eslint + prettier green).

Correctness / bugs:
- B3: match the release checksum to the asset's filename line (no wrong-hash verify)
- B4: newline-safe metadata probe (one --print with a unit-separator delimiter)
- B5 / L88: guard the meta event against canceled items; progress no longer promotes
  a queued item outside pump()
- B7: cookie-login promise always resolves (handles destroy-without-close)
- L146: trim parser rejects >2 colon-group times; M36: Library selection counts only
  actionable rows
- L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message,
  schedule picker min, dead-code/comment cleanup

Type safety:
- Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed)

Resilience / Windows / metadata:
- R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth)
- W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window;
  L147 dead macOS branches removed
- CL1: shared stdout markers; package/builder metadata (license, homepage, repository,
  copyright, tsbuildinfo glob)

Copy / docs / tests:
- M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled
- New unit tests for L35 (isValidMediaItem) and L36 (compareVersions)

This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard
work it builds on: background running + auto-download, library clipboard detection,
tray, binary management & library scale, credential encryption at rest, the shared
jsonStore and ui/ primitives, and the eslint/prettier tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:02:54 -04:00
parent a6a8c5f578
commit 1376c2dee8
82 changed files with 4571 additions and 1276 deletions
+69 -16
View File
@@ -10,6 +10,8 @@ import {
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
DEFAULT_DOWNLOAD_OPTIONS,
isYtdlpUpdateChannel,
type Settings,
@@ -27,7 +29,9 @@ const DEFAULTS: Settings = {
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
// greeted by a bright white window despite full system-theme support.
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
@@ -41,14 +45,19 @@ const DEFAULTS: Settings = {
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).
// configured channel so a stale binary can't silently cause YouTube 403s.
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
// Default to stable so a nightly regression doesn't break downloads for
// everyone out of the box (SR2). Users who want the latest YouTube fixes
// can switch to nightly in Settings → Software.
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
// Default off so adding a watched channel doesn't silently fill the user's
// disk on first use (SR3). Enable explicitly once they know what it does.
autoDownloadNew: false,
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
@@ -105,8 +114,7 @@ export function ensureMediaDirs(): void {
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 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)
@@ -133,6 +141,9 @@ function sanitizeOptions(input: unknown): DownloadOptions {
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),
@@ -149,13 +160,19 @@ function getStore(): Store<Settings> {
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 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
// 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
@@ -213,9 +230,11 @@ export function migrateSecretsAtRest(): void {
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
@@ -238,7 +257,8 @@ export function getSettings(): Settings {
}
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
return withDecryptedSecrets(s.store)
cachedSettings = withDecryptedSecrets(s.store)
return cachedSettings
}
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
@@ -264,6 +284,20 @@ function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
// 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
@@ -343,23 +377,42 @@ export function setSettings(partial: Partial<Settings>): Settings {
}
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':
if (typeof value === 'string') s.set(key, value)
// 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. exportBackup
// still writes them in clear (via the decrypted getSettings), as documented.
// 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':
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
// An access token has no spaces; trim before encrypting (like proxy creds).
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
break
}
}
return getSettings()
}