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:
+69
-16
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user