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
+17 -12
View File
@@ -1,14 +1,9 @@
import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation'
import type {
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
interface BackupFile {
version: 1
@@ -22,7 +17,12 @@ export async function exportBackup(win: BrowserWindow | undefined): Promise<Back
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePath) return { ok: false }
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
// Strip credentials (proxy creds, API tokens) so a backup file shared or synced
// to the cloud doesn't leak secrets in plaintext (M22). The user re-enters them
// after import. Shares SECRET_KEYS with settings.ts so the lists can't drift.
const settings = { ...getSettings() }
for (const key of SECRET_KEYS) (settings as Record<string, unknown>)[key] = ''
const payload: BackupFile = { version: 1, settings, templates: listTemplates() }
try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath }
@@ -85,8 +85,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
return `${name}: ${args}`
})
.join('\n')
const more =
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const more = commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const choice = await dialog.showMessageBox(win!, {
type: 'warning',
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
@@ -106,11 +105,17 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
}
if (incomingSettings) {
// Never restore credential fields from a backup. Exports strip them (M22), so an
// imported '' would otherwise wipe a proxy/token already configured on this
// machine; honoring a hand-edited one would reintroduce the leak vector. Either
// way, import leaves the user's existing secrets untouched — they re-enter as needed.
const restored: Partial<Settings> = { ...incomingSettings }
for (const key of SECRET_KEYS) delete restored[key]
// When the user declined to enable custom commands (or there were none to
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands
? incomingSettings
: { ...incomingSettings, customCommandEnabled: false }
? restored
: { ...restored, customCommandEnabled: false }
setSettings(safeSettings)
}
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
+87
View File
@@ -0,0 +1,87 @@
import { deflateSync } from 'zlib'
import { nativeImage, type NativeImage } from 'electron'
// CRC32 table polynomial used by PNG.
function crc32(buf: Buffer): number {
let crc = 0xffffffff
for (const b of buf) {
crc ^= b
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
}
return (crc ^ 0xffffffff) >>> 0
}
function pngChunk(type: string, data: Buffer): Buffer {
const t = Buffer.from(type, 'ascii')
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length)
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
return Buffer.concat([len, t, data, crc])
}
/**
* Build a 16×16 RGBA PNG containing a solid-colour filled circle. Used for the
* Windows taskbar overlay badge. No external deps — pure Node.js (Buffer + zlib).
*/
function makeDotPng(r: number, g: number, b: number): NativeImage {
const W = 16,
H = 16
const px = Buffer.alloc(W * H * 4, 0) // transparent bg
const cx = W / 2,
cy = H / 2,
rad = W / 2 - 1.5
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x + 0.5 - cx,
dy = y + 0.5 - cy
if (dx * dx + dy * dy <= rad * rad) {
const i = (y * W + x) * 4
px[i] = r
px[i + 1] = g
px[i + 2] = b
px[i + 3] = 255
}
}
}
// PNG: IHDR (8-bit RGBA, no interlace), filter-0 rows, deflated IDAT, IEND.
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(W, 0)
ihdr.writeUInt32BE(H, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // colour type: RGBA
const rows = Buffer.alloc(H * (1 + W * 4))
for (let y = 0; y < H; y++) {
rows[y * (1 + W * 4)] = 0 // filter type: None
px.copy(rows, y * (1 + W * 4) + 1, y * W * 4, (y + 1) * W * 4)
}
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const png = Buffer.concat([
sig,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(rows)),
pngChunk('IEND', Buffer.alloc(0))
])
return nativeImage.createFromBuffer(png)
}
// Lazy-initialised singletons — created on first use so this module is safe to
// import before the app is ready (nativeImage.createFromBuffer is fine post-ready).
let activeBadge: NativeImage | null = null
let errorBadge: NativeImage | null = null
/** Green dot badge for the taskbar overlay — shown when downloads are active. */
export function getActiveBadge(): NativeImage {
if (!activeBadge) activeBadge = makeDotPng(58, 185, 108) // #3ab96c
return activeBadge
}
/** Red dot badge for the taskbar overlay — shown when a download has errored. */
export function getErrorBadge(): NativeImage {
if (!errorBadge) errorBadge = makeDotPng(217, 48, 37) // #d93025
return errorBadge
}
+19 -2
View File
@@ -1,4 +1,4 @@
import { app } from 'electron'
import { app, nativeImage, type NativeImage } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
@@ -24,6 +24,23 @@ export function getAppIconPath(): string {
: join(process.resourcesPath, 'icon.ico')
}
let appIconImage: NativeImage | null = null
/**
* The app icon as a cached NativeImage, for OS notifications (W13/L127). Loaded
* once from getAppIconPath(); a missing icon yields an empty image, which
* Notification treats as "no icon" (the OS default) rather than erroring. This
* gives completion/background toasts the real brand glyph — notably on the
* portable build, which has no installed AUMID shortcut icon for Windows to use.
*/
export function getAppIconImage(): NativeImage {
// Cache only a valid image so a transient read miss isn't latched forever; a
// genuinely-missing icon just re-reads (cheap — notifications are infrequent).
if (!appIconImage || appIconImage.isEmpty()) {
appIconImage = nativeImage.createFromPath(getAppIconPath())
}
return appIconImage
}
/**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what
@@ -35,7 +52,7 @@ export function getAppIconPath(): string {
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
*/
export function getManagedBinDir(): string {
function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
}
+47 -13
View File
@@ -42,9 +42,7 @@ function videoFormat(quality: string): string {
*/
function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio
? opts.formatId
: `${opts.formatId}+bestaudio/${opts.formatId}`
return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
}
return videoFormat(opts.quality)
}
@@ -62,13 +60,19 @@ function audioQuality(quality: string): string {
}
}
// Stdout line markers, shared by the emit side (the --print templates here) and
// the parse side (download.ts). Defined once so changing a marker can't silently
// break the parser that splits on it (CL1).
export const PROGRESS_MARKER = 'prog|'
export const FILEPATH_MARKER = 'path|'
// The progress line yt-dlp emits (one per --newline tick). Note the leading
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
// (it does NOT appear in the output). The literal `prog|` that follows is our
// (it does NOT appear in the output). The PROGRESS_MARKER that follows is our
// own marker, so we can tell progress lines apart from the after-move filepath
// print on the same stdout stream.
export const PROGRESS_TEMPLATE =
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
const PROGRESS_TEMPLATE =
`download:${PROGRESS_MARKER}%(progress.status)s|%(progress.downloaded_bytes)s|` +
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s'
@@ -77,8 +81,7 @@ export const PROGRESS_TEMPLATE =
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
export const CROP_SQUARE_PPA =
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + "crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
/**
* Phase B "Access & networking" settings — not per-download options, always
@@ -127,7 +130,9 @@ export function parseExtraArgs(raw: string): string[] {
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
args.push(m[1] ?? m[2] ?? m[3])
// Exactly one of the three alternation groups matches; the '' fallback only
// satisfies the type checker (noUncheckedIndexedAccess) and never fires.
args.push(m[1] ?? m[2] ?? m[3] ?? '')
}
return args
}
@@ -197,7 +202,10 @@ export function matchesUrl(pattern: string, url: string): boolean {
*/
export function parseTrimSections(raw: string | undefined): string[] {
if (!raw) return []
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
// A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old
// `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside
// yt-dlp's --download-sections rather than being rejected up front (L146).
const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?`
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
const out: string[] = []
for (const piece of raw.split(/[,\n]/)) {
@@ -244,7 +252,11 @@ export function sanitizeDirSegment(name: string): string {
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
s = s
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '')
.replace(/^[. ]+/, '')
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
@@ -328,7 +340,23 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
// Split into one file per chapter. yt-dlp also keeps the full file; the
// per-chapter files use yt-dlp's default `chapter:` output template.
if (o.splitChapters) args.push('--split-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Metadata embedding + optional per-field overrides.
// --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata
// FROM:TO because the replacement arg is a separate element (no colon-splitting
// on values like "Foo: Bar"). ^.*$ matches any string including empty. The only
// escaping Python's re.sub needs in the replacement string is backslash.
const metaOverrides: [string, string | undefined][] = [
['title', o.metadataTitle],
['artist', o.metadataArtist],
['album', o.metadataAlbum]
]
const hasOverrides = metaOverrides.some(([, v]) => v?.trim())
if (o.embedMetadata || hasOverrides) args.push('--embed-metadata')
const escRepl = (s: string): string => s.replace(/\\/g, '\\\\')
for (const [field, val] of metaOverrides) {
if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim()))
}
// Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
@@ -351,6 +379,12 @@ export function buildArgs(
'--newline',
'--no-color',
'--no-playlist',
// Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a
// stalled socket instead of hanging forever and pinning a concurrency slot
// with no recovery. The app-side idle watchdog in download.ts is the backstop
// for a fully-wedged process. (B1)
'--socket-timeout',
'30',
// --print (below) implies --quiet, which would suppress progress; --progress
// forces the progress template to emit anyway.
'--progress',
@@ -362,7 +396,7 @@ export function buildArgs(
PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later.
'--print',
'after_move:path|%(filepath)s',
`after_move:${FILEPATH_MARKER}%(filepath)s`,
'--no-simulate'
]
+45
View File
@@ -0,0 +1,45 @@
import { Menu, type WebContents, type MenuItemConstructorOptions } from 'electron'
/**
* Give editable fields (and any selected text) the standard Windows Cut / Copy /
* Paste / Select All right-click menu that Electron does not provide by default
* (W4) — plus spellcheck suggestions for text areas. The menu items use built-in
* roles, so the editing works directly in the sandboxed, context-isolated
* renderer without any extra IPC. Wired onto the main window only; the untrusted
* cookie sign-in window deliberately keeps its minimal chrome.
*/
export function attachEditContextMenu(wc: WebContents): void {
wc.on('context-menu', (_e, params) => {
const { isEditable, editFlags, selectionText, dictionarySuggestions } = params
// Only worth a menu over an editable field or a real text selection.
if (!isEditable && !selectionText.trim()) return
const template: MenuItemConstructorOptions[] = []
// Spellcheck suggestions first, when the caret sits on a misspelling.
if (isEditable && dictionarySuggestions.length > 0) {
for (const suggestion of dictionarySuggestions) {
template.push({ label: suggestion, click: () => wc.replaceMisspelling(suggestion) })
}
template.push({ type: 'separator' })
}
if (isEditable) {
template.push(
{ role: 'cut', enabled: editFlags.canCut },
{ role: 'copy', enabled: editFlags.canCopy },
{ role: 'paste', enabled: editFlags.canPaste },
{ type: 'separator' },
{ role: 'selectAll', enabled: editFlags.canSelectAll }
)
} else {
// Read-only text with a selection: offer Copy (and Select All).
template.push(
{ role: 'copy', enabled: editFlags.canCopy },
{ role: 'selectAll', enabled: editFlags.canSelectAll }
)
}
Menu.buildFromTemplate(template).popup()
})
}
+128 -18
View File
@@ -1,5 +1,5 @@
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
@@ -7,9 +7,10 @@ import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
/**
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
* from the main window's (default) session so a logged-in site can't see or
* touch anything the app itself loads.
* touch anything the app itself loads. Exported so the PO-token window can
* share the same session (and thus the user's YouTube sign-in state).
*/
const PARTITION = 'persist:aerofetch-login'
export const LOGIN_PARTITION = 'persist:aerofetch-login'
/**
* The sign-in window renders untrusted remote content, so every navigation and
@@ -43,7 +44,7 @@ function hardenLoginWebContents(wc: WebContents): void {
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: PARTITION,
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
@@ -60,20 +61,99 @@ function hardenLoginWebContents(wc: WebContents): void {
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
}
export function getCookiesFilePath(): string {
// The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
// a plaintext file, so the stored form is encrypted via Electron safeStorage
// (DPAPI on Windows — the same protection settings secrets get) and only ever
// decrypted to a short-lived temp file for the duration of a download. This
// matters for the portable build, where userData sits next to the exe (USB /
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
function cookieStorePath(): string {
return join(app.getPath('userData'), 'cookies.dat')
}
function legacyCookiePath(): string {
return join(app.getPath('userData'), 'cookies.txt')
}
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
const ENC_PREFIX = 'enc:v1:'
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
function storeCookies(netscape: string): void {
let out = netscape
try {
if (safeStorage.isEncryptionAvailable()) {
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
}
} catch {
/* fall through — store plaintext, as it was before encryption existed */
}
writeFileSync(cookieStorePath(), out)
}
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
function loadCookies(): string | null {
const p = cookieStorePath()
if (!existsSync(p)) return null
const stored = readFileSync(p, 'utf8')
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
try {
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
} catch {
// A different Windows user/machine (portable jar copied elsewhere) or a
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
return null
}
}
/**
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
* encrypted store, deleting the plaintext so a logged-in session no longer sits
* in the open after an update (H7). Best-effort; safe to call on every launch.
*/
export function migrateLegacyCookies(): void {
const legacy = legacyCookiePath()
try {
if (!existsSync(legacy)) return
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
unlinkSync(legacy)
} catch {
/* best-effort */
}
}
/** Whether a stored cookie jar exists (regardless of decryptability here). */
export function hasStoredCookies(): boolean {
return existsSync(cookieStorePath())
}
/**
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
* false when there's nothing usable to write. The caller deletes `dest` once the
* download settles, keeping the plaintext window as short as possible.
*/
export function materializeCookies(dest: string): boolean {
const text = loadCookies()
if (text == null) return false
try {
writeFileSync(dest, text)
return true
} catch {
return false
}
}
export function getCookiesStatus(): CookiesStatus {
const p = getCookiesFilePath()
const p = cookieStorePath()
if (!existsSync(p)) return { exists: false }
return { exists: true, savedAt: statSync(p).mtimeMs }
}
export async function clearCookies(): Promise<void> {
const p = getCookiesFilePath()
const p = cookieStorePath()
if (existsSync(p)) unlinkSync(p)
await session.fromPartition(PARTITION).clearStorageData()
await session.fromPartition(LOGIN_PARTITION).clearStorageData()
}
/**
@@ -89,9 +169,15 @@ function toNetscapeCookieFile(cookies: Cookie[]): string {
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
lines.push(
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
'\t'
)
[
domain,
includeSubdomains,
c.path || '/',
c.secure ? 'TRUE' : 'FALSE',
expiry,
c.name,
c.value
].join('\t')
)
}
return lines.join('\n') + '\n'
@@ -102,10 +188,13 @@ let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
function exportAndResolve(): void {
session
.fromPartition(PARTITION)
.fromPartition(LOGIN_PARTITION)
.cookies.get({})
.then((cookies) => {
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
// Don't persist an empty jar: closing the window without signing in would
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
// turns cookieCount === 0 into a "did you sign in?" hint.
if (cookies.length > 0) storeCookies(toNetscapeCookieFile(cookies))
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
pendingResolvers.forEach((resolve) => resolve(result))
})
@@ -124,7 +213,10 @@ function exportAndResolve(): void {
* window — cookies are exported to a Netscape-format file at that point,
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
*/
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> {
export function openCookieLoginWindow(
url: string,
parent?: BrowserWindow
): Promise<CookiesLoginResult> {
return new Promise((resolve) => {
let validUrl: string
try {
@@ -136,7 +228,9 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
if (loginWindow && !loginWindow.isDestroyed()) {
pendingResolvers.push(resolve)
loginWindow.loadURL(validUrl).catch(() => {})
loginWindow
.loadURL(validUrl)
.catch((e) => console.error('[AeroFetch] cookie login loadURL failed:', e))
loginWindow.focus()
return
}
@@ -149,8 +243,11 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
height: 720,
title: 'Sign in — AeroFetch',
autoHideMenuBar: true,
// Group under the app window instead of taking its own taskbar button
// (W6); a child window stays above its parent without a modal block.
parent: parent && !parent.isDestroyed() ? parent : undefined,
webPreferences: {
partition: PARTITION,
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
@@ -170,13 +267,26 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
hardenLoginWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
// 'close' fires on a normal user close; a programmatic destroy() fires only
// 'closed'. Latch whichever runs first so the partition is exported exactly
// once and the promise can never hang (B7) — without this, a destroy()
// without a preceding 'close' would leave pendingResolvers pending forever.
let exportStarted = false
const exportOnce = (): void => {
if (exportStarted) return
exportStarted = true
exportAndResolve()
}
win.on('closed', () => {
loginWindow = null
// Fallback for destroy()/closed-without-close: the partition outlives the
// window, so a late export still captures whatever cookies were collected.
exportOnce()
})
// Closing the window IS "I'm done" — export whatever the partition
// collected. The partition itself outlives the window, so this is safe
// even though cookies.get() resolves after 'close' has already fired.
win.on('close', exportAndResolve)
win.on('close', exportOnce)
win.loadURL(validUrl).catch(() => {
/* navigation errors surface as Chromium's own error page */
+2 -1
View File
@@ -30,7 +30,8 @@ function readUrlShortcut(path: string): string | null {
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
const bytes = readSync(fd, buf, 0, buf.length, 0)
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
return match ? asHttpUrl(match[1].trim()) : null
const url = match?.[1]?.trim()
return url ? asHttpUrl(url) : null
} catch {
return null
} finally {
+107 -32
View File
@@ -1,5 +1,6 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { existsSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
@@ -8,11 +9,12 @@ import {
getAria2cPath,
getFfmpegPath,
getFfprobePath,
getSystem32Path
getSystem32Path,
getAppIconImage
} from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { getCookiesFilePath } from './cookies'
import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation'
@@ -20,7 +22,9 @@ import {
buildArgs,
selectExtraArgs,
formatCommandLine,
collectionOutputTemplate
collectionOutputTemplate,
PROGRESS_MARKER,
FILEPATH_MARKER
} from './buildArgs'
import { cleanError } from './log'
import { addErrorLog } from './errorlog'
@@ -43,6 +47,14 @@ interface ActiveDownload {
const active = new Map<string, ActiveDownload>()
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
// stderr — for this long, treat it as wedged, kill it, and error the item so the
// concurrency slot frees instead of leaking forever. Generous on purpose so a
// long, output-less post-processing step (e.g. a large ffmpeg merge) isn't
// mistaken for a stall; --socket-timeout (buildArgs) handles the common
// dead-connection case at the network layer.
const STALL_TIMEOUT_MS = 5 * 60_000
/**
* Whether any yt-dlp download is currently running. Used by the window's close
* handler to keep the app alive in the tray (instead of quitting and killing the
@@ -98,7 +110,10 @@ function parseProgress(rest: string): DownloadProgress | null {
progress,
speed: fmtSpeed(num(speed)),
eta: fmtEta(num(eta)),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined,
// No (estimated) total → the % can never advance; flag it so the renderer
// shows an indeterminate bar rather than a stuck 0% (L137).
sizeUnknown: !totalBytes
}
}
@@ -109,7 +124,7 @@ function send(wc: WebContents, ev: DownloadEvent): void {
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
function notify(wc: WebContents, title: string, body: string): void {
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
const n = new Notification({ title, body })
const n = new Notification({ title, body, icon: getAppIconImage() })
n.on('click', () => {
if (wc.isDestroyed()) return
const win = BrowserWindow.fromWebContents(wc)
@@ -135,6 +150,12 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
// --- Best-effort metadata probe (runs alongside the download) ---------------
// Unit Separator (0x1F): a control char that can't appear in a title/uploader,
// so it safely delimits the three fields in ONE --print template. Splitting on
// newlines instead (B4) would mis-assign channel/duration whenever a title
// itself contains a newline, since each --print field is emitted on its own line.
const META_SEP = '\u001f'
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
return new Promise((resolve) => {
execFile(
@@ -144,20 +165,15 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
'--no-warnings',
'--skip-download',
'--print',
'title',
'--print',
'uploader',
'--print',
'duration_string',
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
'--',
url
],
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
(err, stdout) => {
if (err) return resolve(null)
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
const clean = (v?: string): string | undefined =>
v && v !== 'NA' ? v : undefined
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
resolve({
title: clean(title),
channel: clean(uploader),
@@ -175,6 +191,9 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
// in the renderer UI, so the renderer can't be trusted to apply it.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
// Gate the file read: listTemplates() parses templates.json on every call, so
// skip it entirely when the feature is off (PERF2).
if (!settings.customCommandEnabled) return []
return selectExtraArgs({
customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs,
@@ -184,8 +203,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
})
}
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
/** Resolve settings + per-download overrides into the full yt-dlp argv.
* `cookiesFile` is the transient decrypted jar the caller materialises for the
* 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
const settings = getSettings()
// Output dir resolution: a per-download override wins, then the user's explicit
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
@@ -210,12 +231,6 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
// into resources/bin — the toggle shouldn't turn into a hard error.
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
// Same idea: 'login' cookies only apply once the sign-in window has actually
// exported a file; otherwise the download proceeds cookie-less rather than failing.
const cookiesFile =
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
? getCookiesFilePath()
: undefined
const access = {
proxy: settings.proxy,
rateLimit: settings.rateLimit,
@@ -250,10 +265,7 @@ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult
// --- Public API -------------------------------------------------------------
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
// Self-heal the managed copy from the bundled seed before spawning, so a
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
ensureManagedYtdlp()
@@ -301,10 +313,30 @@ export function startDownload(
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
}
// Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
// yt-dlp reads --cookies once at startup; we delete it the moment the download
// settles, so the plaintext never lingers at rest.
let cookiesFile: string | undefined
if (getSettings().cookieSource === 'login' && hasStoredCookies()) {
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}.txt`)
if (materializeCookies(tmp)) cookiesFile = tmp
}
function cleanupCookies(): void {
if (cookiesFile) {
try {
unlinkSync(cookiesFile)
} catch {
/* best-effort */
}
cookiesFile = undefined
}
}
let child: ChildProcess
try {
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true })
} catch (e) {
cleanupCookies()
return { ok: false, error: (e as Error).message }
}
@@ -331,31 +363,72 @@ export function startDownload(
let stdoutBuf = ''
let stderrTail = ''
let filePath: string | undefined
// Latched once the first download stream reports 'finished'; flags later
// progress as the merge/post-processing "finishing" phase (SR7).
let finishing = false
// 'error' and 'close' can both fire for one process; only act on the first.
let settled = false
// Idle watchdog (B1): reset on any output; if it ever fires, the child has been
// silent for STALL_TIMEOUT_MS, so kill + error it and free the slot.
let stallTimer: ReturnType<typeof setTimeout> | null = null
function clearWatchdog(): void {
if (stallTimer) {
clearTimeout(stallTimer)
stallTimer = null
}
}
function bumpWatchdog(): void {
clearWatchdog()
stallTimer = setTimeout(() => {
if (settled) return
settled = true
clearWatchdog()
cleanupCookies()
active.delete(opts.id)
killTree(rec)
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, resolvedTitle, msg)
notify(wc, resolvedTitle ?? 'Download failed', msg)
}, STALL_TIMEOUT_MS)
}
bumpWatchdog()
child.stdout?.on('data', (chunk: Buffer) => {
bumpWatchdog()
stdoutBuf += chunk.toString()
let nl: number
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line.startsWith('prog|')) {
const p = parseProgress(line.slice('prog|'.length))
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
} else if (line.startsWith('path|')) {
filePath = line.slice('path|'.length).trim()
if (line.startsWith(PROGRESS_MARKER)) {
const p = parseProgress(line.slice(PROGRESS_MARKER.length))
if (p) {
// SR7: yt-dlp reports a 'finished' status when each download stream
// completes. A video+audio download has two streams, so the bar would
// otherwise fill 0→100% twice. Latch on the first 'finished' and flag
// every later tick as "finishing" so the renderer shows an indeterminate
// merge state instead of a visible restart.
if (p.status === 'finished') finishing = true
send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
}
} else if (line.startsWith(FILEPATH_MARKER)) {
filePath = line.slice(FILEPATH_MARKER.length).trim()
}
}
})
child.stderr?.on('data', (chunk: Buffer) => {
bumpWatchdog()
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
})
child.on('error', (err) => {
if (settled) return
settled = true
clearWatchdog()
cleanupCookies()
active.delete(opts.id)
// A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) {
@@ -368,6 +441,8 @@ export function startDownload(
child.on('close', (code) => {
if (settled) return
settled = true
clearWatchdog()
cleanupCookies()
active.delete(opts.id)
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event.
+9 -30
View File
@@ -1,45 +1,24 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { ErrorLogEntry } from '@shared/ipc'
import { isValidErrorLogEntry } from './validation'
import { createJsonStore } from './jsonStore'
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
// report survives the queue item being cleared (Seal's "debug report").
// report survives the queue item being cleared (Seal's "debug report"). Atomic
// writes / corruption backup / caching come from the shared jsonStore (R1R3).
const MAX_ENTRIES = 200
function errorLogFile(): string {
return join(app.getPath('userData'), 'errorlog.json')
}
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, MAX_ENTRIES)
// Per-entry validation (isValidErrorLogEntry, in validation.ts) so a hand-edited
// or corrupted errorlog.json can't feed the UI entries with the wrong shape —
// invalid rows are dropped. (audit S5)
export function listErrorLog(): ErrorLogEntry[] {
try {
if (!existsSync(errorLogFile())) return []
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
} catch {
return []
}
}
function save(entries: ErrorLogEntry[]): void {
try {
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted error log */
}
return store.read()
}
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
const entries = [entry, ...listErrorLog()]
save(entries)
return entries
return store.write([entry, ...listErrorLog()])
}
export function clearErrorLog(): ErrorLogEntry[] {
save([])
return []
return store.write([])
}
+1 -1
View File
@@ -21,7 +21,7 @@ function readToolVersion(path: string): Promise<string | null> {
}
const firstLine = stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
resolve(m ? m[1] : null)
resolve(m?.[1] ?? null)
})
})
}
+17 -37
View File
@@ -1,59 +1,39 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation'
import { createJsonStore } from './jsonStore'
// Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later.
// Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic
// writes, corruption backup, and caching come from the shared jsonStore (R1R3).
const MAX_ENTRIES = 500
function historyFile(): string {
return join(app.getPath('userData'), 'history.json')
}
// Per-entry validation (isValidHistoryEntry) so a hand-edited or corrupted
// history.json can't feed the UI (or openPath) entries with the wrong shape —
// invalid rows are dropped rather than trusted. (audit S5)
const store = createJsonStore('history.json', isValidHistoryEntry, MAX_ENTRIES)
// Per-entry validation (isValidHistoryEntry, in validation.ts) so a hand-edited
// or corrupted history.json can't feed the UI (or openPath) entries with the
// wrong shape — invalid rows are dropped rather than trusted. (audit S5)
export function listHistory(): HistoryEntry[] {
try {
if (!existsSync(historyFile())) return []
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
} catch {
return []
}
}
function save(entries: HistoryEntry[]): void {
try {
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted history */
}
return store.read()
}
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
// De-dupe by id (a retry of the same item replaces its prior entry).
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)]
save(entries)
return entries
// De-dupe by id AND url (M35): a History re-download re-queues via addFromUrl,
// which mints a NEW id, so id-only de-dup would let the same video accumulate a
// fresh row on every re-download. Dropping any prior entry with the same url
// keeps one row per video, refreshed to the top.
const prior = listHistory().filter((e) => e.id !== entry.id && e.url !== entry.url)
return store.write([entry, ...prior])
}
export function removeHistory(id: string): HistoryEntry[] {
const entries = listHistory().filter((e) => e.id !== id)
save(entries)
return entries
return store.write(listHistory().filter((e) => e.id !== id))
}
export function removeManyHistory(ids: string[]): HistoryEntry[] {
const remove = new Set(ids)
const entries = listHistory().filter((e) => !remove.has(e.id))
save(entries)
return entries
return store.write(listHistory().filter((e) => !remove.has(e.id)))
}
export function clearHistory(): HistoryEntry[] {
save([])
return []
return store.write([])
}
+117 -34
View File
@@ -1,5 +1,16 @@
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron'
import {
app,
shell,
BrowserWindow,
ipcMain,
dialog,
clipboard,
nativeTheme,
Notification,
Menu
} from 'electron'
import { join, resolve } from 'path'
import { existsSync } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import {
IpcChannels,
@@ -15,6 +26,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import { getAppIconImage } from './binaries'
import {
startDownload,
cancelDownload,
@@ -34,8 +46,15 @@ import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
import {
openCookieLoginWindow,
getCookiesStatus,
clearCookies,
migrateLegacyCookies
} from './cookies'
import { attachEditContextMenu } from './contextMenu'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { flushAllStores } from './jsonStore'
import { exportBackup, importBackup } from './backup'
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
import {
@@ -50,6 +69,8 @@ import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
import { createTray, markQuitting, isQuitting } from './tray'
import { getActiveBadge, getErrorBadge } from './badge'
import { openPoTokenWindow } from './poToken'
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
@@ -79,8 +100,9 @@ setupPortableData()
// installer also declares this scheme (electron-builder.yml's `protocols`)
// so it's registered even before first launch; this call additionally covers
// the portable build and dev, which have no installer step to do it for us.
if (is.dev && process.argv.length >= 2) {
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])])
const devScript = process.argv[1]
if (is.dev && devScript) {
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)])
} else {
app.setAsDefaultProtocolClient('aerofetch')
}
@@ -93,13 +115,18 @@ let mainWindow: BrowserWindow | null = null
// Windows) shows the app's current color instead of a mismatched white flash.
const THEME_BACKGROUND = { light: '#f7f7f8', dark: '#161618' } as const
// 'system' isn't a real background — resolve it against the OS's current
// preference (nativeTheme.themeSource defaults to 'system', so this tracks it
// without AeroFetch ever touching themeSource itself).
// Resolve 'system' against the OS preference so callers always get a concrete color.
function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
}
// Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting
// themeSource to 'light'/'dark' forces the caption/chrome to match; 'system'
// restores OS control. Called at startup and on every theme change.
function applyNativeTheme(theme: Settings['theme']): void {
nativeTheme.themeSource = theme
}
function getSystemThemeInfo(): SystemThemeInfo {
return {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
@@ -116,7 +143,8 @@ function notifyBackgroundOnce(): void {
notifiedBackground = true
new Notification({
title: 'AeroFetch is still running',
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.'
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
icon: getAppIconImage()
}).show()
}
@@ -143,6 +171,10 @@ function createWindow(): void {
const win = new BrowserWindow({
width: 920,
height: 700,
// Below this the 212px sidebar + content layout breaks; pin a sensible
// floor so the window can't be dragged down to unusable widths (W1).
minWidth: 640,
minHeight: 480,
show: false,
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
autoHideMenuBar: true,
@@ -155,6 +187,9 @@ function createWindow(): void {
})
mainWindow = win
// Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4).
attachEditContextMenu(win.webContents)
win.on('ready-to-show', () => {
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
@@ -226,6 +261,16 @@ function createWindow(): void {
}
function registerIpcHandlers(): void {
// M30: a broken contextBridge causes the renderer to silently run in preview/mock
// mode. Catch it here and show a hard error so the failure is never invisible.
ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => {
dialog.showErrorBox(
'AeroFetch could not start',
`The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}`
)
app.quit()
})
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
@@ -262,13 +307,17 @@ function registerIpcHandlers(): void {
)
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const res = await dialog.showOpenDialog(win!, {
properties: ['openDirectory', 'createDirectory']
})
// Seed the picker at the currently-configured folder so it opens where the
// user already points, not a generic default (W5). 'createDirectory' is a
// macOS-only property and a no-op on Windows, so it's dropped (L58).
const defaultPath = current && existsSync(current) ? current : undefined
// Use the parented overload only when we actually have a window — passing a
// forced non-null window that's gone can throw (L53).
const res = win
? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath })
: await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath })
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
@@ -280,13 +329,14 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
const result = setSettings(partial)
// Keep the window's native background in sync with the theme so a compositor
// repaint never flashes a mismatched color behind an overlay. Use the
// validated result, not the raw partial (which may hold a bogus value).
// Keep the window's native background and title bar in sync with the theme
// so a compositor repaint never flashes a mismatched color, and the OS-drawn
// caption always matches the in-app theme (W3). Use the validated result.
if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
)
applyNativeTheme(result.theme)
}
return result
})
@@ -303,12 +353,18 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) =>
// Parent the sign-in window to the app window (W6) so it groups under
// AeroFetch instead of spawning a second taskbar button.
openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) =>
saveTemplate(template)
)
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
@@ -327,9 +383,11 @@ function registerIpcHandlers(): void {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const result = await importBackup(win)
// A restored backup may have changed the theme; keep the native window
// background in sync the same way settingsSet does.
// background and title bar in sync the same way settingsSet does (W3).
if (result.ok) {
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)])
const theme = getSettings().theme
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(theme)])
applyNativeTheme(theme)
}
return result
})
@@ -369,8 +427,24 @@ function registerIpcHandlers(): void {
// Reflect overall queue progress on the Windows taskbar (fire-and-forget).
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
if (!mainWindow || mainWindow.isDestroyed()) return
if (p.mode === 'none') mainWindow.setProgressBar(-1)
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
if (p.mode === 'none') {
mainWindow.setProgressBar(-1)
mainWindow.setOverlayIcon(null, '')
} else {
mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge()
const n = p.badgeCount ?? 0
const label =
p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active`
mainWindow.setOverlayIcon(badge, label)
}
})
// Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P).
ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => {
const token = await openPoTokenWindow()
if (token) await setSettings({ youtubePoToken: token })
return token
})
}
@@ -400,6 +474,10 @@ if (isPrimaryInstance) {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
// M31: suppress the default Electron menu (which exposes DevTools/Reload via
// Alt) in production builds. In dev the menu is kept so DevTools are accessible.
if (!is.dev) Menu.setApplicationMenu(null)
// Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind).
ensureMediaDirs()
@@ -407,6 +485,9 @@ if (isPrimaryInstance) {
// Encrypt any credential still stored as legacy plaintext (from before at-rest
// encryption), once safeStorage is available post-ready.
migrateSecretsAtRest()
// Same for a pre-encryption plaintext cookies.txt — encrypt it and delete the
// plaintext so a logged-in session no longer sits in the open (H7).
migrateLegacyCookies()
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
// reflects the user's choice even if they changed it on another install.
@@ -419,6 +500,8 @@ if (isPrimaryInstance) {
registerIpcHandlers()
registerSystemThemeBridge()
registerSendToShortcut()
// Apply the persisted theme to the OS title bar before the window opens (W3).
applyNativeTheme(getSettings().theme)
createWindow()
createTray(() => mainWindow)
@@ -443,20 +526,20 @@ if (isPrimaryInstance) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
}
}).catch(() => {})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
}).catch((e) => console.error('[AeroFetch] yt-dlp auto-update failed:', e))
})
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
// the quitting flag so the window's close handler exits instead of hiding to tray.
app.on('before-quit', () => markQuitting())
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
// Also flush any debounced JSON-store writes synchronously so a quit mid-debounce
// can't drop the last history/sources/template change (R3).
app.on('before-quit', () => {
markQuitting()
flushAllStores()
})
// Windows-only app: closing the last window quits (the tray/in-flight-download
// paths hide rather than close, so this only fires on a real exit). The former
// macOS 'activate' handler and darwin guard were dead branches here (L147).
app.on('window-all-closed', () => app.quit())
}
+2 -1
View File
@@ -152,7 +152,8 @@ export async function indexSource(
if (entries.length === 0) {
return {
ok: false,
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
error:
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
}
}
title = data.title || 'Playlist'
+5 -2
View File
@@ -88,7 +88,10 @@ export function classifySource(raw: string): SourceClass | null {
// A playlist is identified purely by its list= param (works on any youtube host).
const list = u.searchParams.get('list')
if (isYouTube && list) {
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
return {
kind: 'playlist',
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
}
}
if (!isYouTube) return null
@@ -163,7 +166,7 @@ export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = []
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
let m: RegExpExecArray | null
while ((m = re.exec(xml)) !== null) ids.push(m[1])
while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
return ids
}
+130
View File
@@ -0,0 +1,130 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'fs'
/**
* One shared persistence layer for the hand-rolled JSON array stores (history,
* error log, templates, sources, media-items). Replaces the per-module
* read/parse/write copies (SIMP1) and fixes three data-safety blockers:
*
* - R1 — atomic writes: write a temp file then `rename` over the target, so a
* crash / power-cut mid-write can never truncate the real file. (`electron-store`,
* used for settings, is already atomic; these stores were not.)
* - R2 — corruption is no longer silent data loss: on a parse error the bad file
* is copied aside (`<file>.corrupt-<ts>`) before we fall back to empty, so a
* single bad byte can't wipe the user's history/sources from their perspective.
* - R3 — an in-memory cache + debounced batched writes: the previous code
* re-read + re-parsed the entire (multi-MB) file and rewrote it synchronously
* on *every* download completion (≈O(n²) over a large channel, blocking the
* main thread). Reads now hit the cache; writes coalesce into one atomic flush.
*/
// Debounce window for batched writes. Long enough to coalesce a burst (e.g. many
// items enqueued/marked at once), short enough that data lands on disk promptly.
const FLUSH_DELAY_MS = 400
/**
* Read + validate a JSON array from `path`. Invalid rows are dropped. On a parse
* error the file is backed up to `<path>.corrupt-<timestamp>` before returning []
* so corruption is recoverable rather than a silent wipe (R2). A missing file is
* simply [] (first run), with no backup.
*/
export function readJsonArraySafe<T>(path: string, isValid: (o: unknown) => o is T): T[] {
let raw: string
try {
if (!existsSync(path)) return []
raw = readFileSync(path, 'utf8')
} catch {
return []
}
try {
const data = JSON.parse(raw)
return Array.isArray(data) ? data.filter(isValid) : []
} catch {
try {
copyFileSync(path, `${path}.corrupt-${Date.now()}`)
} catch {
/* best-effort — if we can't back it up, still don't crash the read */
}
return []
}
}
/**
* Atomically write `value` as pretty JSON to `path`: serialise to `<path>.tmp`,
* then `rename` it over the target (an atomic operation on the same volume), so a
* crash mid-write leaves either the old file or the new one — never a truncated
* one (R1). Best-effort: a read-only data dir just means nothing is persisted.
*/
export function writeJsonAtomic(path: string, value: unknown): void {
const tmp = `${path}.tmp`
try {
writeFileSync(tmp, JSON.stringify(value, null, 2))
renameSync(tmp, path)
} catch {
/* best-effort; e.g. a read-only data dir */
}
}
export interface JsonStore<T> {
/** All rows, validated; cached after the first read. */
read(): T[]
/** Replace all rows (capped), update the cache, and schedule an atomic flush. Returns the stored rows. */
write(items: T[]): T[]
/** Force any pending debounced write to disk synchronously (e.g. on quit). */
flush(): void
}
// Every store registers itself so app-quit can flush pending writes in one call.
const registry: JsonStore<unknown>[] = []
/**
* Create a cached, atomic JSON array store backed by `<userData>/<filename>`.
* `cap` bounds the row count (pass Infinity for none). The path is resolved
* lazily so importing this module never touches `app` before it's ready.
*/
export function createJsonStore<T>(
filename: string,
isValid: (o: unknown) => o is T,
cap: number
): JsonStore<T> {
let cache: T[] | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let dirty = false
const filePath = (): string => join(app.getPath('userData'), filename)
function read(): T[] {
if (cache === null) cache = readJsonArraySafe(filePath(), isValid)
return cache
}
function flush(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
if (!dirty) return
dirty = false
writeJsonAtomic(filePath(), cache ?? [])
}
function write(items: T[]): T[] {
// Avoid an extra copy in the common under-cap case so a per-completion write
// stays O(1) rather than re-slicing the whole list each time (R3).
cache = items.length > cap ? items.slice(0, cap) : items
dirty = true
if (timer) clearTimeout(timer)
timer = setTimeout(flush, FLUSH_DELAY_MS)
return cache
}
const store: JsonStore<T> = { read, write, flush }
registry.push(store as JsonStore<unknown>)
return store
}
/** Synchronously flush every store's pending write — call on app quit. */
export function flushAllStores(): void {
for (const s of registry) s.flush()
}
+148
View File
@@ -0,0 +1,148 @@
import { BrowserWindow, type WebContents } from 'electron'
import { LOGIN_PARTITION } from './cookies'
/** A public YouTube video used as the extraction target (stable, always accessible). */
const YT_VIDEO = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
/**
* Restrict a PO-token window to *.youtube.com and accounts.google.com (for
* sign-in). Mirrors hardenLoginWebContents in cookies.ts.
*/
function isAllowedPoTokenUrl(target: string): boolean {
try {
const { protocol, hostname } = new URL(target)
if (protocol !== 'http:' && protocol !== 'https:') return false
return (
hostname === 'youtube.com' ||
hostname.endsWith('.youtube.com') ||
hostname === 'accounts.google.com' ||
hostname.endsWith('.accounts.google.com')
)
} catch {
return false
}
}
function hardenPoTokenWebContents(wc: WebContents): void {
wc.setWindowOpenHandler((details) => {
if (!isAllowedPoTokenUrl(details.url)) return { action: 'deny' }
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
}
}
})
wc.on('will-navigate', (e, navUrl) => {
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
})
wc.on('will-redirect', (e, navUrl) => {
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
})
wc.on('did-create-window', (child) => hardenPoTokenWebContents(child.webContents))
}
/**
* Async IIFE injected into the YouTube page after load. Polls
* ytInitialPlayerResponse.serviceIntegrityDimensions.poToken (the field
* yt-dlp's youtube extractor reads) for up to 8 s, returning the token
* string or null if it never appears. The poll loop is needed because the
* player JS initialises asynchronously after the DOM is ready.
*/
const EXTRACT_SCRIPT = `
(async function() {
for (let i = 0; i < 16; i++) {
try {
const pot = window.ytInitialPlayerResponse
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions.poToken
if (typeof pot === 'string' && pot.length > 0) return pot
} catch (_) {}
await new Promise(r => setTimeout(r, 500))
}
return null
})()
`
let mintWindow: BrowserWindow | null = null
/**
* Open a visible BrowserWindow on a YouTube video page and extract the
* Proof-of-Origin token from the page runtime. The window uses the shared
* login session so a signed-in user's credentials are available.
*
* Resolves with the token string on success, or null if the window was closed
* by the user before extraction completed or if the field was not found.
*/
export function openPoTokenWindow(): Promise<string | null> {
// If a minting window is already open, bring it to the front rather than
// opening a second one.
if (mintWindow && !mintWindow.isDestroyed()) {
mintWindow.focus()
// Return a promise that resolves when the existing window closes.
return new Promise((resolve) => {
mintWindow!.once('closed', () => resolve(null))
})
}
return new Promise((resolve) => {
let resolved = false
const finish = (token: string | null): void => {
if (resolved) return
resolved = true
resolve(token)
}
let win: BrowserWindow
try {
win = new BrowserWindow({
width: 960,
height: 640,
title: 'AeroFetch — Fetch YouTube token',
autoHideMenuBar: true,
webPreferences: {
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
} catch {
finish(null)
return
}
mintWindow = win
hardenPoTokenWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _perm, cb) => cb(false))
win.webContents.once('did-finish-load', () => {
win.webContents
.executeJavaScript(EXTRACT_SCRIPT, true)
.then((result: unknown) => {
const token = typeof result === 'string' && result.length > 0 ? result : null
finish(token)
win.close()
})
.catch(() => {
finish(null)
win.close()
})
})
win.on('closed', () => {
mintWindow = null
finish(null)
})
win.loadURL(YT_VIDEO).catch(() => {
/* navigation errors surface as Chromium's own error page */
})
})
}
+21 -3
View File
@@ -13,11 +13,29 @@ import { extname, isAbsolute } from 'path'
*/
const OPENABLE_EXTENSIONS = new Set([
// video
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv',
'.mp4',
'.mkv',
'.webm',
'.mov',
'.avi',
'.flv',
'.ts',
'.m4v',
'.3gp',
'.ogv',
// audio
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma',
'.mp3',
'.m4a',
'.opus',
'.ogg',
'.oga',
'.aac',
'.flac',
'.wav',
'.wma',
// subtitle sidecars (plain text — safe to open)
'.vtt', '.srt'
'.vtt',
'.srt'
])
/** Open a downloaded media file with its default app. Returns '' on success,
+9 -4
View File
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
return new Promise((resolve) => {
// Resolve schtasks from System32 by absolute path, not the bare name, so a
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
})
execFile(
getSystem32Path('schtasks.exe'),
args,
{ windowsHide: true },
(err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
}
)
})
}
+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()
}
+15 -42
View File
@@ -12,48 +12,27 @@
* can't feed the UI malformed records (same approach as history/errorlog).
*/
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore'
import { createJsonStore } from './jsonStore'
// A generous global cap so a runaway index can't grow the file unbounded; large
// enough for several big channels. When exceeded, the most-recently-written
// source's items are kept (they're placed first by replaceMediaItems).
const MAX_ITEMS = 20000
function sourcesFile(): string {
return join(app.getPath('userData'), 'sources.json')
}
function itemsFile(): string {
return join(app.getPath('userData'), 'media-items.json')
}
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
try {
if (!existsSync(path)) return []
const data = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(data) ? data.filter(isValid) : []
} catch {
return []
}
}
function writeJson(path: string, value: unknown): void {
try {
writeFileSync(path, JSON.stringify(value, null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted index */
}
}
// Two cached, atomically-written stores (R1R3 via the shared jsonStore). The
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
// rewrite the whole (≤MAX_ITEMS) file on every completion; now reads hit the
// cache and writes are batched. Sources are few, so that store is uncapped.
const sourcesStore = createJsonStore('sources.json', isValidSource, Infinity)
const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS)
// --- Sources ----------------------------------------------------------------
export function listSources(): Source[] {
return readJsonArray(sourcesFile(), isValidSource)
return sourcesStore.read()
}
export function getSource(id: string): Source | undefined {
@@ -62,31 +41,25 @@ export function getSource(id: string): Source | undefined {
/** Insert or replace a source by id (a re-index updates the existing record). */
export function upsertSource(source: Source): Source[] {
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
writeJson(sourcesFile(), sources)
return sources
return sourcesStore.write([source, ...listSources().filter((s) => s.id !== source.id)])
}
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
export function setSourceWatched(id: string, watched: boolean): Source[] {
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
writeJson(sourcesFile(), sources)
return sources
return sourcesStore.write(listSources().map((s) => (s.id === id ? { ...s, watched } : s)))
}
/** Remove a source and all of its media items. Returns the remaining sources. */
export function removeSource(id: string): Source[] {
const sources = listSources().filter((s) => s.id !== id)
writeJson(sourcesFile(), sources)
const items = listAllItems().filter((m) => m.sourceId !== id)
writeJson(itemsFile(), items)
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
itemsStore.write(listAllItems().filter((m) => m.sourceId !== id))
return sources
}
// --- Media items ------------------------------------------------------------
function listAllItems(): MediaItem[] {
return readJsonArray(itemsFile(), isValidMediaItem)
return itemsStore.read()
}
export function listMediaItems(sourceId: string): MediaItem[] {
@@ -100,7 +73,7 @@ export function listMediaItems(sourceId: string): MediaItem[] {
*/
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS))
itemsStore.write([...items, ...others])
}
/**
@@ -130,6 +103,6 @@ export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem
const updated = all.map((m) =>
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
)
writeJson(itemsFile(), updated)
itemsStore.write(updated)
return updated.filter((m) => m.sourceId === target.sourceId)
}
+11 -34
View File
@@ -1,38 +1,21 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { CommandTemplate } from '@shared/ipc'
import { isTemplateLike } from './validation'
import { createJsonStore } from './jsonStore'
// Plain JSON in userData, same shape as history.ts.
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
// backup / caching come from the shared jsonStore (R1R3).
const MAX_TEMPLATES = 100
function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
// A persisted template entry must at least be an object carrying an id (see
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
// Drop anything that isn't, so a hand-edited templates.json can't inject
// malformed entries. (audit S5)
export function listTemplates(): CommandTemplate[] {
try {
if (!existsSync(templatesFile())) return []
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
// Validate shape, then normalise each surviving entry through sanitize() so
// name/args are always well-formed strings regardless of what was on disk.
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
} catch {
return []
}
}
const store = createJsonStore('templates.json', isTemplateLike, MAX_TEMPLATES)
function save(templates: CommandTemplate[]): void {
try {
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted templates */
}
export function listTemplates(): CommandTemplate[] {
// Normalise each surviving entry through sanitize() so name/args are always
// well-formed strings regardless of what was on disk.
return store.read().map(sanitize)
}
function sanitize(t: CommandTemplate): CommandTemplate {
@@ -49,20 +32,14 @@ function sanitize(t: CommandTemplate): CommandTemplate {
/** Add a new template, or update an existing one (matched by id). */
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
const clean = sanitize(template)
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
save(templates)
return templates
return store.write([clean, ...listTemplates().filter((t) => t.id !== clean.id)])
}
export function removeTemplate(id: string): CommandTemplate[] {
const templates = listTemplates().filter((t) => t.id !== id)
save(templates)
return templates
return store.write(listTemplates().filter((t) => t.id !== id))
}
/** Replace the entire template list (backup restore) rather than merge-by-id. */
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
const clean = templates.map(sanitize)
save(clean)
return clean
return store.write(templates.map(sanitize))
}
+2 -1
View File
@@ -44,7 +44,8 @@ export function createTray(getWindow: () => BrowserWindow | null): void {
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico
// ships, so minimize-to-tray always has a tray to restore from.
let icon = nativeImage.createFromPath(getAppIconPath())
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
if (icon.isEmpty())
icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
if (icon.isEmpty()) return
tray = new Tray(icon)
+29 -7
View File
@@ -68,9 +68,10 @@ function updateDir(): string {
* an "upgrade" over the same shipped version.
*/
function parseVersion(v: string): number[] {
return v
.replace(/^v/i, '')
.split(/[-+]/)[0]
// split() always yields at least one element; `?? ''` only satisfies the type
// checker (noUncheckedIndexedAccess) for the [0] access.
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
return core
.split('.')
.map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n))
@@ -105,9 +106,28 @@ interface GiteaRelease {
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
* lowercase digest, or null if there's no standalone 64-char hex token (so a
* longer run like a sha512 digest is ignored rather than sliced).
*
* When `fileName` is given (the installer asset's name), a multi-line / combined
* checksum file is matched line-by-line and the hash on the line naming THIS
* asset wins — so a `<asset>.sha256` that happens to list several files can't
* verify the installer against the wrong line's hash (B3). Falls back to the
* first standalone digest for bare single-hash files (no filename present).
*/
export function extractSha256(text: string): string | null {
const m = text.match(/\b[a-f0-9]{64}\b/i)
export function extractSha256(text: string, fileName?: string): string | null {
const hashRe = /\b[a-f0-9]{64}\b/i
if (fileName) {
const base = fileName.toLowerCase()
for (const line of text.split(/\r?\n/)) {
const m = line.match(hashRe)
if (!m) continue
// The filename is the last whitespace-delimited token on a sha256sum /
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
if (last.toLowerCase() === base) return m[0].toLowerCase()
}
}
const m = text.match(hashRe)
return m ? m[0].toLowerCase() : null
}
@@ -200,7 +220,9 @@ function fetchTrustedText(
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
return new Promise((resolve) => {
let settled = false
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
const done = (
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
): void => {
if (settled) return
settled = true
clearTimeout(timer)
@@ -261,7 +283,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
const sum = await fetchTrustedText(checksumUrl)
let expectedSha: string | null = null
if (sum.ok) {
expectedSha = extractSha256(sum.text)
expectedSha = extractSha256(sum.text, safeName)
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
} else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) {
+15 -13
View File
@@ -32,10 +32,7 @@ export function ensureManagedYtdlp(): void {
}
}
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end.
*/
/** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
const ytdlpPath = getYtdlpPath()
@@ -47,16 +44,21 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
}
return new Promise((resolve) => {
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out running yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return
execFile(
ytdlpPath,
['--version'],
{ windowsHide: true, timeout: 15_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out running yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return
}
resolve({ ok: true, version: stdout.trim() })
}
resolve({ ok: true, version: stdout.trim() })
})
)
})
}
+24 -19
View File
@@ -57,8 +57,7 @@ const api = {
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
},
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
@@ -69,20 +68,16 @@ const api = {
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
ipcRenderer.invoke(IpcChannels.downloadStart, opts),
cancelDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
cancelDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadCancel, id),
pauseDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadPause, id),
pauseDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadPause, id),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
chooseFolder: (current?: string): Promise<string | null> =>
ipcRenderer.invoke(IpcChannels.chooseFolder, current),
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
showInFolder: (path: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.showInFolder, path),
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannels.showInFolder, path),
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
@@ -184,8 +179,7 @@ const api = {
reindexSource: (id: string): Promise<IndexSourceResult> =>
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
removeSource: (id: string): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
removeSource: (id: string): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourceRemove, id),
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
@@ -220,8 +214,7 @@ const api = {
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
cancelTerminal: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
cancelTerminal: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.terminalCancel, id),
/** Subscribe to live terminal output. Returns an unsubscribe function. */
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
@@ -232,7 +225,11 @@ const api = {
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */
setTaskbarProgress: (p: TaskbarProgress): void =>
ipcRenderer.send(IpcChannels.taskbarProgress, p)
ipcRenderer.send(IpcChannels.taskbarProgress, p),
/** Open a YouTube WebView to automatically extract a PO token (Phase P). */
mintPoToken: (): Promise<string | null> =>
ipcRenderer.invoke(IpcChannels.youtubePoTokenMint) as Promise<string | null>
}
export type Api = typeof api
@@ -248,11 +245,19 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electron)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
// M30: a broken bridge causes the renderer to silently run in preview/mock mode.
// Notify the main process so it can show a hard error dialog — without this the
// failure is completely invisible to the user.
console.error('[AeroFetch preload] contextBridge failed:', error)
try {
ipcRenderer.send(IpcChannels.preloadBridgeFailure, String(error))
} catch {
/* if IPC itself is broken there is nothing more we can do */
}
}
} else {
// @ts-ignore (defined in index.d.ts)
// @ts-ignore window.electron is declared in index.d.ts
window.electron = electron
// @ts-ignore (defined in index.d.ts)
// @ts-ignore window.api is declared in index.d.ts
window.api = api
}
+11 -3
View File
@@ -8,11 +8,13 @@ import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useDownloads } from './store/downloads'
import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
const useStyles = makeStyles({
provider: {
@@ -58,14 +60,19 @@ function App(): React.JSX.Element {
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode, badgeCount: s.downloading })
}
push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items))
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{
id: 'go-downloads',
label: 'Go to Downloads',
hint: 'Navigate',
run: () => setTab('downloads')
},
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
@@ -91,7 +98,7 @@ function App(): React.JSX.Element {
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
}, [])
// Sidebar collapse, persisted across launches in localStorage.
@@ -151,6 +158,7 @@ function App(): React.JSX.Element {
)}
</>
)}
<LiveRegion />
</div>
</FluentProvider>
)
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing'
export interface PaletteAction {
id: string
@@ -35,7 +36,6 @@ const useStyles = makeStyles({
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
@@ -91,6 +91,7 @@ export function CommandPalette({
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
@@ -133,7 +134,7 @@ export function CommandPalette({
>
<input
ref={inputRef}
className={styles.input}
className={mergeClasses(styles.input, focus.focusRing)}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
@@ -148,7 +149,11 @@ export function CommandPalette({
<button
key={a.id}
type="button"
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
className={mergeClasses(
styles.item,
i === sel && styles.itemActive,
focus.focusRing
)}
onClick={() => {
a.run()
onClose()
+39 -72
View File
@@ -33,7 +33,10 @@ import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings'
import { Select } from './Select'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useClipboardLink, looksLikeUrl } from '../useClipboardLink'
import { logError } from '../reportError'
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
function firstUrl(text: string): string | null {
@@ -148,34 +151,6 @@ const useStyles = makeStyles({
flexDirection: 'column',
gap: '4px'
},
segmented: {
display: 'inline-flex',
width: 'fit-content',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
segment: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
padding: '7px 16px',
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
segmentActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
},
quality: {
minWidth: '220px'
},
@@ -224,24 +199,6 @@ const useStyles = makeStyles({
flexGrow: 1,
minWidth: 0
},
plKindBtn: {
flexShrink: 0,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
@@ -296,6 +253,14 @@ const useStyles = makeStyles({
}
})
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
// in LOCAL time — used as the picker's `min` so a past time can't be chosen and
// then silently download immediately (L156/L57).
function toLocalDatetimeValue(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
@@ -307,7 +272,7 @@ export function DownloadBar(): React.JSX.Element {
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
@@ -346,7 +311,7 @@ export function DownloadBar(): React.JSX.Element {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(() => {})
.catch(logError('dropped .url file read'))
}
}
@@ -384,7 +349,10 @@ export function DownloadBar(): React.JSX.Element {
dismiss: dismissSuggestion,
offer: offerLink
} = useClipboardLink(url)
useEffect(() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')), [offerLink])
useEffect(
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
[offerLink]
)
function acceptSuggestion(): void {
const link = acceptLink()
@@ -393,7 +361,7 @@ export function DownloadBar(): React.JSX.Element {
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
const selectedFormat: FormatOption | undefined = usingFormats
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
: undefined
function clearProbe(): void {
@@ -573,7 +541,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.urlRow}>
<Input
className={styles.url}
input={{ id: 'aerofetch-url' }}
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
@@ -716,19 +684,22 @@ export function DownloadBar(): React.JSX.Element {
/>
<Hint
label={
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
effKind(e.index) === 'audio'
? 'Audio — click for video'
: 'Video — click for audio'
}
placement="top"
align="end"
>
<button
type="button"
className={styles.plKindBtn}
<IconButton
size="sm"
style={{ flexShrink: 0 }}
icon={
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
>
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
</button>
/>
</Hint>
</div>
))}
@@ -781,6 +752,7 @@ export function DownloadBar(): React.JSX.Element {
type="datetime-local"
className={styles.dtInput}
value={scheduleAt}
min={toLocalDatetimeValue(new Date())}
onChange={(e) => setScheduleAt(e.target.value)}
/>
</Field>
@@ -792,20 +764,15 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.controls}>
<div className={styles.control}>
<Caption1>Format</Caption1>
<div className={styles.segmented} role="radiogroup" aria-label="Format">
{(['video', 'audio'] as MediaKind[]).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={kind === k}
className={mergeClasses(styles.segment, kind === k && styles.segmentActive)}
onClick={() => onKindChange(k)}
>
{k === 'video' ? 'Video' : 'Audio'}
</button>
))}
</div>
<SegmentedControl<MediaKind>
value={kind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={onKindChange}
ariaLabel="Format"
/>
</div>
<div className={styles.control}>
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
sponsor: 'Sponsor',
intro: 'Intro / intermission',
outro: 'Endcards / credits',
outro: 'End cards / credits',
selfpromo: 'Self-promotion',
preview: 'Preview / recap',
filler: 'Filler / tangent',
@@ -123,7 +123,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</Field>
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
<Field
label="Preferred codec"
hint="A preference when several formats match — not a strict filter."
>
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
@@ -144,7 +147,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
/>
</Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
<Switch
checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
@@ -232,6 +235,28 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
label={value.embedMetadata ? 'On' : 'Off'}
/>
</Field>
<Field
label="Metadata overrides"
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
>
<div className={styles.subGroup}>
<Input
placeholder="Title"
value={value.metadataTitle ?? ''}
onChange={(_, d) => setOpt('metadataTitle', d.value)}
/>
<Input
placeholder="Artist"
value={value.metadataArtist ?? ''}
onChange={(_, d) => setOpt('metadataArtist', d.value)}
/>
<Input
placeholder="Album"
value={value.metadataAlbum ?? ''}
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
/>
</div>
</Field>
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
<Switch
checked={value.embedThumbnail}
+16 -2
View File
@@ -5,6 +5,7 @@ import {
Button,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -14,6 +15,7 @@ import { summarizeQueue } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
const useStyles = makeStyles({
root: {
@@ -70,6 +72,7 @@ const useStyles = makeStyles({
export function DownloadsView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
@@ -78,9 +81,20 @@ export function DownloadsView(): React.JSX.Element {
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
// The header count is the live queue (work not yet finished), not the whole
// list — completed/canceled/error rows linger until "Clear finished" (L11).
const queueCount = items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
).length
return (
<div className={styles.root}>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
<DownloadBar />
{summary.active && (
@@ -96,7 +110,7 @@ export function DownloadsView(): React.JSX.Element {
)}
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
<Subtitle2>Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
@@ -0,0 +1,100 @@
import React from 'react'
interface Props {
children: React.ReactNode
}
interface State {
error: Error | null
}
/**
* Top-level renderer error boundary (M16). Before this, an exception thrown in
* render by any view unmounted the whole shell to a blank white window with no
* way out. This catches it, logs it (so it reaches the dev console / future log
* sink), and shows a recover affordance.
*
* The fallback is intentionally dependency-free no Fluent components, no theme
* provider because the error may have come from inside that very tree. It paints
* its own full-window dark surface (matching the app's dark charcoal + toffee
* accent) rather than reading theme tokens, so it renders identically regardless
* of the active theme and uses only inline CSS that can't itself throw.
*/
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[AeroFetch] renderer crashed:', error, info.componentStack)
}
private handleReload = (): void => {
// A full reload re-runs the renderer from a clean state; persisted data
// (settings/history/sources) lives in main, so nothing is lost.
window.location.reload()
}
render(): React.ReactNode {
const { error } = this.state
if (!error) return this.props.children
return (
<div
role="alert"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
minHeight: '100vh',
padding: 32,
textAlign: 'center',
fontFamily: 'Segoe UI, system-ui, sans-serif',
color: '#c8c6c4',
background: '#201f1e'
}}
>
<div style={{ fontSize: 18, fontWeight: 600, color: '#f3f2f1' }}>Something went wrong</div>
<div style={{ maxWidth: 440, fontSize: 13, lineHeight: 1.5 }}>
AeroFetch hit an unexpected error and couldnt continue. Your downloads and settings are
saved reloading the window should bring you back.
</div>
<pre
style={{
maxWidth: 480,
maxHeight: 120,
overflow: 'auto',
margin: 0,
padding: '8px 12px',
fontSize: 11,
textAlign: 'left',
color: '#d29ca0',
background: '#2b2a29',
borderRadius: 6
}}
>
{error.message || String(error)}
</pre>
<button
type="button"
onClick={this.handleReload}
style={{
padding: '8px 20px',
fontSize: 14,
fontWeight: 600,
color: '#1c1611',
background: '#b5917d',
border: 'none',
borderRadius: 8,
cursor: 'pointer'
}}
>
Reload AeroFetch
</button>
</div>
)
}
}
+117 -38
View File
@@ -7,6 +7,7 @@ import {
Input,
Body1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -22,6 +23,7 @@ import {
} from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { formatWhen } from '../datetime'
import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
@@ -29,6 +31,8 @@ import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { Select } from './Select'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { useFocusStyles } from './ui/focusRing'
const useStyles = makeStyles({
root: {
@@ -115,7 +119,7 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
borderRadius: tokens.borderRadiusCircular,
fontSize: '26px'
},
emptyHint: {
@@ -134,19 +138,10 @@ const KIND_FILTER_OPTIONS = [
{ value: 'audio', label: 'Audio' }
]
function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export function HistoryView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
@@ -161,6 +156,8 @@ export function HistoryView(): React.JSX.Element {
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
const [selectMode, setSelectMode] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmClear, setConfirmClear] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
@@ -196,6 +193,7 @@ export function HistoryView(): React.JSX.Element {
function exitSelectMode(): void {
setSelectMode(false)
setSelected(new Set())
setConfirmDelete(false)
}
function deleteSelected(): void {
@@ -205,21 +203,25 @@ export function HistoryView(): React.JSX.Element {
if (entries.length === 0) {
return (
<div className={styles.empty}>
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.empty}>
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
</div>
<Body1>No downloads yet.</Body1>
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
</div>
<Body1>No downloads yet.</Body1>
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
</div>
)
}
return (
<div className={styles.root}>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.header}>
{selectMode ? (
<>
@@ -228,18 +230,44 @@ export function HistoryView(): React.JSX.Element {
{allFilteredSelected ? 'Select none' : 'Select all'}
</Button>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
Cancel
</Button>
{confirmDelete ? (
<>
<Caption1 className={styles.count}>
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
>
Delete
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => setConfirmDelete(true)}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={exitSelectMode}
>
Cancel
</Button>
</>
)}
</>
) : (
<>
@@ -248,6 +276,7 @@ export function HistoryView(): React.JSX.Element {
</Caption1>
<Input
className={styles.search}
input={{ 'aria-label': 'Search history' }}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
@@ -270,9 +299,36 @@ export function HistoryView(): React.JSX.Element {
>
Select
</Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
{confirmClear ? (
<>
<Caption1 className={styles.count}>
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
clear()
setConfirmClear(false)
}}
>
Clear all
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmClear(true)}
>
Clear history
</Button>
)}
</>
)}
</div>
@@ -280,10 +336,33 @@ export function HistoryView(): React.JSX.Element {
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div className={styles.list}>
<div
className={styles.list}
onKeyDown={(e) => {
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
// select mode if needed). Scoped to the list, so it never hijacks
// Ctrl+A in the search field, which lives in the header above.
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault()
setSelectMode(true)
setSelected(new Set(filtered.map((x) => x.id)))
}
}}
>
{filtered.map((h: HistoryEntry) => {
return (
<div key={h.id} className={styles.row}>
<div
key={h.id}
className={mergeClasses(styles.row, focus.focusRing)}
tabIndex={0}
onKeyDown={(e) => {
// Delete removes the focused row (not when a child button is focused).
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
remove(h.id)
}
}}
>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
+80 -84
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import {
Subtitle2,
Body1,
Caption1,
Text,
@@ -33,10 +32,15 @@ import type { MediaItem, Source } from '@shared/ipc'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -50,26 +54,13 @@ type ItemStatus = DownloadStatus | 'pending'
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
| { kind: 'header'; title: string; items: MediaItem[] }
| { kind: 'item'; item: MediaItem }
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100
const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending',
queued: 'Queued',
downloading: 'Downloading',
paused: 'Paused',
saved: 'Saved',
completed: 'Downloaded',
error: 'Failed',
canceled: 'Canceled'
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 },
@@ -198,27 +189,7 @@ const useStyles = makeStyles({
textOverflow: 'ellipsis',
color: tokens.colorNeutralForeground1
},
rowMeta: { color: tokens.colorNeutralForeground3 },
pill: {
flexShrink: 0,
fontSize: tokens.fontSizeBase200,
padding: '1px 8px',
...shorthands.borderRadius(tokens.borderRadiusCircular),
backgroundColor: tokens.colorNeutralBackground3,
color: tokens.colorNeutralForeground3
},
pillDownloading: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
pillCompleted: {
backgroundColor: tokens.colorPaletteGreenBackground2,
color: tokens.colorPaletteGreenForeground2
},
pillError: {
backgroundColor: tokens.colorPaletteRedBackground2,
color: tokens.colorPaletteRedForeground2
}
rowMeta: { color: tokens.colorNeutralForeground3 }
})
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
@@ -241,18 +212,10 @@ function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[
return groups
}
function relTime(ms?: number): string {
if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago`
return `${Math.round(hrs / 24)} d ago`
}
export function LibraryView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId)
@@ -271,6 +234,13 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
// Reset any pending Remove confirmation when the expanded source changes so a
// stale confirm can't reappear after navigating between sources.
useEffect(() => {
setConfirmRemoveId(null)
}, [selectedSourceId])
// Offer a freshly-copied link the way the Downloads tab does, but skip single
// videos — a library source is a channel/playlist to sync, not a one-off.
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
@@ -287,7 +257,10 @@ export function LibraryView(): React.JSX.Element {
// Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => {
if (PREVIEW) return
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
window.api
.getScheduledSync()
.then((s) => setScheduled(s.enabled))
.catch(logError('getScheduledSync'))
}, [])
async function onCheckNew(): Promise<void> {
@@ -322,8 +295,7 @@ export function LibraryView(): React.JSX.Element {
const groups = useMemo(() => groupByPlaylist(items), [items])
// A group is shown when toggled open, or auto-expanded when it's the only group
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
const isGroupOpen = (title: string): boolean =>
groups.length === 1 || expandedGroups.has(title)
const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = []
@@ -385,6 +357,9 @@ export function LibraryView(): React.JSX.Element {
setSelected((prev) => {
const next = new Set(prev)
for (const it of groupItems) {
// Only actionable rows are selectable, so the selection count can never
// exceed what "Download N selected" will actually queue (M36).
if (!actionable(it)) continue
if (on) next.add(it.id)
else next.delete(it.id)
}
@@ -422,13 +397,6 @@ export function LibraryView(): React.JSX.Element {
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function pillClass(status: ItemStatus): string {
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
return styles.pill
}
// One row of the item list — a playlist header or a video — shared by the
// inline (small source) and virtualized (large source) render paths.
function rowKey(row: LibRow): string {
@@ -437,18 +405,19 @@ export function LibraryView(): React.JSX.Element {
function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') {
const allOn = row.items.every((it) => selected.has(it.id))
const open = isGroupOpen(row.title)
const groupActionable = row.items.filter(actionable).length
// "All on" is judged over the ACTIONABLE rows only — downloaded rows aren't
// selectable, so they must not keep the group from reading as fully selected (M36).
const groupActionableItems = row.items.filter(actionable)
const groupActionable = groupActionableItems.length
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
return (
<div
className={styles.groupHead}
className={mergeClasses(styles.groupHead, focus.focusRing)}
onClick={() => toggleGroupExpand(row.title)}
role="button"
tabIndex={0}
onKeyDown={(e) =>
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)}
aria-expanded={open}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
@@ -486,6 +455,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
disabled={!actionable(it)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
@@ -501,23 +471,22 @@ export function LibraryView(): React.JSX.Element {
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
<StatusChip status={status} />
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Library</Subtitle2>
<Caption1 className={styles.sub}>
Index a channel or playlist once, then download it into organized folders.
</Caption1>
</div>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Library"
description="Index a channel or playlist once, then download it into organized folders."
/>
<div className={styles.addRow}>
<Input
className={styles.addInput}
input={{ 'aria-label': 'Channel or playlist URL' }}
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
@@ -615,9 +584,7 @@ export function LibraryView(): React.JSX.Element {
styles={styles}
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() =>
selectSource(selectedSourceId === src.id ? null : src.id)
}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
@@ -634,7 +601,11 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.actionSpacer} />
{selected.size > 0 ? (
<>
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
<Button
size="small"
appearance="subtle"
onClick={() => setSelected(new Set())}
>
Clear
</Button>
<Button
@@ -675,14 +646,38 @@ export function LibraryView(): React.JSX.Element {
>
Re-index
</Button>
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => removeSource(src.id)}
>
Remove
</Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={styles.sub}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
removeSource(src.id)
setConfirmRemoveId(null)
}}
>
Remove
</Button>
<Button
size="small"
appearance="subtle"
onClick={() => setConfirmRemoveId(null)}
>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmRemoveId(src.id)}
>
Remove
</Button>
)}
</div>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
@@ -695,7 +690,7 @@ export function LibraryView(): React.JSX.Element {
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
@@ -730,10 +725,11 @@ function SourceCard({
onToggleExpand: () => void
children: React.ReactNode
}): React.JSX.Element {
const focus = useFocusStyles()
return (
<div className={styles.card}>
<div
className={styles.cardHead}
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
role="button"
tabIndex={0}
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles } from '@fluentui/react-components'
import { useDownloads, type DownloadStatus } from '../store/downloads'
const useStyles = makeStyles({
// Visually hidden, but present for screen readers (the standard sr-only recipe).
srOnly: {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap'
}
})
/**
* A polite ARIA live region (W17) so Narrator announces download completions and
* failures even when the user isn't on the Downloads tab. Subscribes to the store
* directly and announces only terminal transitions ( completed / error), so it
* never chatters on progress ticks. Mounted once, near the app root.
*/
export function LiveRegion(): React.JSX.Element {
const styles = useStyles()
const [message, setMessage] = useState('')
const prev = useRef<Map<string, DownloadStatus>>(new Map())
useEffect(() => {
function check(items: ReturnType<typeof useDownloads.getState>['items']): void {
const announcements: string[] = []
for (const it of items) {
if (prev.current.get(it.id) !== it.status) {
if (it.status === 'completed') announcements.push(`Finished downloading ${it.title}`)
else if (it.status === 'error') announcements.push(`Download failed: ${it.title}`)
}
}
prev.current = new Map(items.map((i) => [i.id, i.status]))
if (announcements.length > 0) setMessage(announcements.join('. '))
}
// Seed the baseline without announcing the items already present on mount.
prev.current = new Map(useDownloads.getState().items.map((i) => [i.id, i.status]))
return useDownloads.subscribe((st) => check(st.items))
}, [])
return (
<div className={styles.srOnly} role="status" aria-live="polite" aria-atomic="true">
{message}
</div>
)
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function MediaThumb({
}): React.JSX.Element {
const styles = useStyles()
const isDark = useResolvedDark()
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
const [failedSrc, setFailedSrc] = useState<string | null>(null)
const showImg = !!src && failedSrc !== src
+2 -2
View File
@@ -112,8 +112,8 @@ export function Onboarding(): React.JSX.Element {
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
<strong>Documents\Audio</strong>. You can point each to a different folder any time
in Settings.
<strong>Documents\Audio</strong>. You can point each to a different folder any time in
Settings.
</Caption1>
</Field>
+39 -30
View File
@@ -4,9 +4,9 @@ import {
Caption1,
Button,
ProgressBar,
Badge,
Spinner,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -25,10 +25,13 @@ import {
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { fmtSchedule } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
const useStyles = makeStyles({
root: {
@@ -93,34 +96,17 @@ const useStyles = makeStyles({
}
})
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' },
paused: { label: 'Paused', color: 'warning' },
saved: { label: 'Saved', color: 'subtle' },
completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'warning' }
}
function pct(progress: number): string {
return `${Math.round(progress * 100)}%`
}
function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}
export const QueueItem = memo(function QueueItem({
item
}: {
item: DownloadItem
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const cancel = useDownloads((s) => s.cancel)
const pause = useDownloads((s) => s.pause)
const resume = useDownloads((s) => s.resume)
@@ -132,9 +118,19 @@ export const QueueItem = memo(function QueueItem({
const openFile = useDownloads((s) => s.openFile)
const showInFolder = useDownloads((s) => s.showInFolder)
const badge = STATUS_BADGE[item.status]
const active = item.status === 'downloading' || item.status === 'queued'
// W7: Delete on a focused row removes it — cancelling first if it's still running,
// since an in-flight download can't just be dropped from the list. Only when the
// row itself holds focus, so Delete on one of its action buttons is unaffected.
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
if (active) cancel(item.id)
else remove(item.id)
}
}
const metaParts = [
item.channel,
item.durationLabel,
@@ -144,7 +140,7 @@ export const QueueItem = memo(function QueueItem({
].filter(Boolean)
return (
<div className={styles.root}>
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
@@ -167,9 +163,7 @@ export const QueueItem = memo(function QueueItem({
/>
)}
<Text className={styles.title}>{item.title}</Text>
<Badge appearance="tint" color={badge.color}>
{badge.label}
</Badge>
<StatusChip status={item.status} />
{item.incognito && (
<Hint label="Private — not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
@@ -181,11 +175,24 @@ export const QueueItem = memo(function QueueItem({
{item.status === 'downloading' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
{/* Indeterminate when finishing (the second stream / merge reads as
"working" rather than a 0100% restart, SR7) or when yt-dlp can't
report a total size, so the bar never sits frozen at 0% (L137). */}
<ProgressBar
className={styles.progressBar}
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
thickness="large"
/>
<Caption1 className={styles.stats}>
{pct(item.progress)}
{item.speed ? `${item.speed}` : ''}
{item.eta ? `${item.eta} left` : ''}
{item.finishing ? (
'Finishing…'
) : (
<>
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
{item.speed ? `${item.speed}` : ''}
{item.eta ? `${item.eta} left` : ''}
</>
)}
</Caption1>
</div>
)}
@@ -206,7 +213,9 @@ export const QueueItem = memo(function QueueItem({
{item.status === 'saved' && (
<Caption1 className={styles.stats}>
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
{item.scheduledFor
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
: 'Saved for later'}
</Caption1>
)}
+125 -64
View File
@@ -61,14 +61,16 @@ import { useErrorLog } from '../store/errorlog'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { TemplateManager } from './TemplateManager'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { useErrorTextStyles } from './ui/errorText'
import { ACCENT_OPTIONS } from '../theme'
import { logError } from '../reportError'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
maxWidth: '640px'
gap: '16px'
},
card: {
display: 'flex',
@@ -117,7 +119,7 @@ const useStyles = makeStyles({
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius('50%'),
...shorthands.borderRadius(tokens.borderRadiusCircular),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer'
@@ -148,11 +150,6 @@ const useStyles = makeStyles({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
errorRowText: {
color: tokens.colorPaletteRedForeground1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
})
@@ -176,7 +173,7 @@ const COOKIE_SOURCE_OPTIONS = [
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
value: b,
label: b[0].toUpperCase() + b.slice(1)
label: b.charAt(0).toUpperCase() + b.slice(1)
}))
const UPDATE_CHANNEL_OPTIONS = [
@@ -186,6 +183,8 @@ const UPDATE_CHANNEL_OPTIONS = [
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const errText = useErrorTextStyles()
const screen = useScreenStyles()
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
// filter by toggling each card's `display` based on whether its text matches.
@@ -252,6 +251,8 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
@@ -273,22 +274,23 @@ export function SettingsView(): React.JSX.Element {
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
const [confirmClearLog, setConfirmClearLog] = useState(false)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
useEffect(() => {
window.api.getAppVersion().then(setAppVersion).catch(() => {})
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
}, [])
useEffect(() => {
// Show the current yt-dlp version without a manual click, and reflect a
// background auto-update (which may run on launch) live in this panel.
window.api.getYtdlpVersion().then(setVersion).catch(() => {})
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(() => {})
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
@@ -375,17 +377,14 @@ export function SettingsView(): React.JSX.Element {
}
function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always
// non-empty here — no need for an unreachable "No errors logged." fallback (L159).
const report = errorEntries
.map((e) =>
[
new Date(e.occurredAt).toLocaleString(),
e.title ?? e.url,
e.url,
e.error
].join('\n')
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
)
.join('\n\n---\n\n')
navigator.clipboard.writeText(report || 'No errors logged.').catch(() => {})
navigator.clipboard.writeText(report).catch(() => {})
}
async function signIn(): Promise<void> {
@@ -393,8 +392,14 @@ export function SettingsView(): React.JSX.Element {
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok) setCookiesStatus(await window.api.cookiesStatus())
else setLoginError(result.error ?? 'Sign-in failed.')
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything — don't imply success (L50).
setLoginError('No cookies were captured — did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
setLoginError(result.error ?? 'Sign-in failed.')
}
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
@@ -443,9 +448,16 @@ export function SettingsView(): React.JSX.Element {
}
return (
<div className={styles.root} ref={rootRef}>
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
<div data-settings-search>
<ScreenHeader
title="Settings"
description="Folders, formats, network, cookies, and more."
/>
</div>
<div data-settings-search>
<Input
input={{ 'aria-label': 'Search settings' }}
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
@@ -682,7 +694,7 @@ export function SettingsView(): React.JSX.Element {
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Requires aria2c.exe in resources/bin (see the README there) — falls back to yt-dlp's downloader if it's missing."
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
>
<Switch
checked={useAria2c}
@@ -704,13 +716,47 @@ export function SettingsView(): React.JSX.Element {
<Field
label="YouTube PO token (advanced)"
hint="A manually-obtained Proof-of-Origin token for YouTube's bot check, sent via --extractor-args. Usually cookies (above) are the easier fix; automatic minting is planned."
hint={
potHint ??
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
}
>
<Input
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => update({ youtubePoToken: d.value })}
/>
<div className={styles.folderRow}>
<Input
style={{ flexGrow: 1 }}
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => {
update({ youtubePoToken: d.value })
setPotHint(null)
}}
/>
<Button
size="small"
disabled={mintingPot}
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
onClick={async () => {
setMintingPot(true)
setPotHint(null)
try {
const token = await window.api.mintPoToken()
if (token) {
setPotHint('Token saved.')
} else {
setPotHint(
'Token not found — try signing into YouTube first via Cookies above.'
)
}
} catch {
setPotHint('Failed to open YouTube window.')
} finally {
setMintingPot(false)
}
}}
>
{mintingPot ? 'Fetching…' : 'Fetch'}
</Button>
</div>
</Field>
</Card>
@@ -720,8 +766,8 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a
logged-in session. Supply cookies so yt-dlp can act like one.
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
session. Supply cookies so yt-dlp can act like one.
</Caption1>
<Field label="Cookie source">
@@ -736,7 +782,7 @@ export function SettingsView(): React.JSX.Element {
{cookieSource === 'browser' && (
<Field
label="Browser"
hint="Reads that browser's saved cookies directly. Close it first if yt-dlp can't open a locked cookie database."
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
>
<Select
aria-label="Browser"
@@ -779,9 +825,7 @@ export function SettingsView(): React.JSX.Element {
)}
</div>
{loginError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>{loginError}</Caption1>
)}
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
</>
)}
</Card>
@@ -872,8 +916,9 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on
another machine. Does not include download history.
Save your settings and custom-command templates to a JSON file, or restore them on another
machine. Does not include download history or credentials (proxy, API tokens re-enter
those after import).
</Caption1>
<div className={styles.folderRow}>
@@ -888,17 +933,13 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
)}
{exportResult && !exportResult.ok && exportResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{exportResult.error}
</Caption1>
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{importResult.error}
</Caption1>
<Caption1 className={errText.error}>{importResult.error}</Caption1>
)}
</Card>
@@ -920,9 +961,34 @@ export function SettingsView(): React.JSX.Element {
>
Copy full report
</Button>
<Button icon={<DeleteRegular />} onClick={clearErrorLog} disabled={errorEntries.length === 0}>
Clear log
</Button>
{confirmClearLog ? (
<>
<Caption1>
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
</Caption1>
<Button
icon={<DeleteRegular />}
appearance="primary"
onClick={() => {
clearErrorLog()
setConfirmClearLog(false)
}}
>
Clear log
</Button>
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
Cancel
</Button>
</>
) : (
<Button
icon={<DeleteRegular />}
onClick={() => setConfirmClearLog(true)}
disabled={errorEntries.length === 0}
>
Clear log
</Button>
)}
</div>
{errorEntries.length === 0 ? (
@@ -937,7 +1003,7 @@ export function SettingsView(): React.JSX.Element {
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={styles.errorRowText}>{e.error}</Caption1>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
</div>
@@ -968,12 +1034,12 @@ export function SettingsView(): React.JSX.Element {
<Field
label="Update access token"
hint="Only needed if the release server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only Gitea token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional — Gitea access token"
placeholder="Optional — access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
@@ -995,7 +1061,10 @@ export function SettingsView(): React.JSX.Element {
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
<Button
icon={<OpenRegular />}
onClick={() => window.open(appUpd.htmlUrl, '_blank')}
>
View release
</Button>
)}
@@ -1013,11 +1082,7 @@ export function SettingsView(): React.JSX.Element {
<Text>You&apos;re up to date v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpdError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{appUpdError}
</Caption1>
)}
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
</Card>
<Card className={styles.card}>
@@ -1026,13 +1091,13 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its
own copy of yt-dlp, keeping it up to date automatically.
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
</Caption1>
<Field
label="Keep yt-dlp updated automatically"
hint="Checks once a day on launch and updates yt-dlp in the background, so YouTube changes don't start breaking downloads with 403 errors."
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
>
<Switch
checked={autoUpdateYtdlp}
@@ -1045,9 +1110,7 @@ export function SettingsView(): React.JSX.Element {
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
{version && !version.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{version.error}
</Caption1>
<Caption1 className={errText.errorPre}>{version.error}</Caption1>
)}
{ffmpeg && (
<>
@@ -1094,9 +1157,7 @@ export function SettingsView(): React.JSX.Element {
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{updateResult.error}
</Caption1>
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
)}
</Card>
</div>
+28 -87
View File
@@ -14,6 +14,9 @@ import {
} from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useFocusStyles } from './ui/focusRing'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
@@ -41,24 +44,6 @@ const useStyles = makeStyles({
topBarCollapsed: {
justifyContent: 'center'
},
iconBtn: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
fontSize: '18px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
},
brand: {
display: 'flex',
alignItems: 'center',
@@ -138,41 +123,6 @@ const useStyles = makeStyles({
},
spacer: {
flexGrow: 1
},
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
themeGroup: {
alignSelf: 'stretch',
display: 'flex',
width: '100%',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
themeSeg: {
flex: 1,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
padding: '7px 4px',
fontSize: tokens.fontSizeBase200,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
themeSegActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
}
})
@@ -215,11 +165,13 @@ export function Sidebar({
onToggleCollapsed
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system']
function cycleTheme(): void {
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
const next = order[(order.indexOf(theme) + 1) % order.length]
if (next) onSetTheme(next)
}
const themeIcon =
theme === 'system' ? (
@@ -235,15 +187,12 @@ export function Sidebar({
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<button
type="button"
className={styles.iconBtn}
<IconButton
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
>
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
/>
</Hint>
</div>
@@ -254,7 +203,12 @@ export function Sidebar({
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
{/* Stable tagline so the caption never flips from text to a version
string once it loads (L66); the version shows on hover and in
Settings About. */}
<Caption1 className={styles.caption} title={version ? `Version ${version}` : undefined}>
yt-dlp frontend
</Caption1>
</div>
)}
</div>
@@ -269,7 +223,8 @@ export function Sidebar({
className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive
active && styles.navItemActive,
focus.focusRing
)}
style={
active && !collapsed
@@ -298,34 +253,20 @@ export function Sidebar({
{collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<button
type="button"
className={styles.iconBtn}
<IconButton
icon={themeIcon}
onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`}
>
{themeIcon}
</button>
aria-label={`Change theme (currently ${themeLabel})`}
/>
</Hint>
) : (
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
{THEMES.map((t) => {
const on = theme === t.value
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={on}
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
onClick={() => onSetTheme(t.value)}
>
<span className={styles.navIcon}>{t.icon}</span>
{t.label}
</button>
)
})}
</div>
<SegmentedControl<ThemeMode>
fitted
value={theme}
options={THEMES}
onChange={onSetTheme}
ariaLabel="Theme"
/>
)}
</nav>
)
@@ -9,9 +9,16 @@ import {
tokens,
shorthands
} from '@fluentui/react-components'
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons'
import {
AddRegular,
EditRegular,
DeleteRegular,
SaveRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates'
import { newId } from '../id'
import { Hint } from './Hint'
const useStyles = makeStyles({
@@ -79,10 +86,6 @@ interface Draft {
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
}
/**
* Inline (no-modal see the Hint/Select comments re: composited overlays
* flickering on this dev machine's GPU) CRUD list + add/edit form for
@@ -106,7 +109,7 @@ export function TemplateManager(): React.JSX.Element {
if (!name) return
const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId(),
id: draft.id ?? newId('tpl'),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
@@ -182,7 +185,11 @@ export function TemplateManager(): React.JSX.Element {
</div>
</div>
) : (
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
<Button
appearance="subtle"
icon={<AddRegular />}
onClick={() => setDraft({ ...BLANK_DRAFT })}
>
Add template
</Button>
)}
+23 -17
View File
@@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Subtitle2,
Body1,
Caption1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
@@ -20,8 +22,6 @@ interface Line {
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
gate: {
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
@@ -54,10 +54,6 @@ const useStyles = makeStyles({
empty: { color: tokens.colorNeutralForeground3 }
})
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
}
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
@@ -65,6 +61,7 @@ function newId(): string {
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const [args, setArgs] = useState('')
@@ -101,7 +98,7 @@ export function TerminalView(): React.JSX.Element {
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId()
const id = newId('t')
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setRunning(true)
@@ -126,17 +123,25 @@ export function TerminalView(): React.JSX.Element {
}
const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
kind === 'cmd'
? styles.cmd
: kind === 'stderr'
? styles.stderr
: kind === 'sys'
? styles.sys
: undefined
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Terminal</Subtitle2>
<Caption1 className={styles.sub}>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</Caption1>
</div>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Terminal"
description={
<>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</>
}
/>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
@@ -149,6 +154,7 @@ export function TerminalView(): React.JSX.Element {
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
+24 -17
View File
@@ -42,28 +42,35 @@ export function VirtualList<T>({
estimateSize,
overscan,
gap,
getItemKey: (index) => getKey(items[index], index)
getItemKey: (index) => {
const it = items[index]
return it !== undefined ? getKey(it, index) : index
}
})
return (
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((vrow) => (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(items[vrow.index], vrow.index)}
</div>
))}
{virtualizer.getVirtualItems().map((vrow) => {
const item = items[vrow.index]
if (item === undefined) return null
return (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(item, vrow.index)}
</div>
)
})}
</div>
</div>
)
@@ -0,0 +1,58 @@
import { forwardRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
const useStyles = makeStyles({
btn: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
borderRadius: tokens.borderRadiusMedium,
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
},
':disabled': {
cursor: 'not-allowed',
color: tokens.colorNeutralForegroundDisabled,
backgroundColor: 'transparent'
}
},
md: { width: '32px', height: '32px', fontSize: '18px' },
sm: { width: '28px', height: '28px', fontSize: '16px' }
})
type IconButtonProps = {
icon: React.JSX.Element
size?: 'sm' | 'md'
} & React.ButtonHTMLAttributes<HTMLButtonElement>
/**
* The recurring icon-only ghost button (UI15) one implementation for the
* sidebar's collapse/theme toggles and the playlist row's video/audio switch,
* which were separate hand-rolled buttons with the same look. Carries the shared
* focus ring (UI29). Extra props (onClick, aria-label, aria-pressed, disabled, )
* pass straight through to the underlying `<button>`.
*/
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ icon, size = 'md', className, type = 'button', ...rest },
ref
) {
const styles = useStyles()
const focus = useFocusStyles()
return (
<button
ref={ref}
type={type}
className={mergeClasses(styles.btn, styles[size], focus.focusRing, className)}
{...rest}
>
{icon}
</button>
)
})
+74
View File
@@ -0,0 +1,74 @@
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
/**
* One shared content max-width for every screen (UI1).
*
* Settings used to be a 640px column while the list screens (Downloads, Library,
* History, Terminal) were full-width, so on a wide window Settings read as an odd
* narrow strip beside edge-to-edge siblings. This caps them all at the same
* reading width and centers, so they line up. On typical window sizes the content
* is already under the cap, so the list screens are visually unchanged only the
* wide-window upper bound is now shared.
*
* Merge `screen.width` onto each screen's existing root with `mergeClasses`.
*/
export const useScreenStyles = makeStyles({
width: {
width: '100%',
maxWidth: '1200px',
marginLeft: 'auto',
marginRight: 'auto'
}
})
const useStyles = makeStyles({
header: {
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
flexWrap: 'wrap'
},
titleBlock: {
display: 'flex',
flexDirection: 'column',
gap: '2px',
minWidth: 0,
flexGrow: 1
},
description: {
color: tokens.colorNeutralForeground3
},
actions: {
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0
}
})
/**
* The consistent screen header block (UI9/UI10): one page-title style on every
* screen, with an optional one-line description and an optional right-aligned
* action slot. Previously each screen rolled its own (Subtitle2 / Title2 / none),
* and only Library had a description.
*/
export function ScreenHeader({
title,
description,
actions
}: {
title: string
description?: React.ReactNode
actions?: React.ReactNode
}): React.JSX.Element {
const styles = useStyles()
return (
<div className={styles.header}>
<div className={styles.titleBlock}>
<Subtitle2>{title}</Subtitle2>
{description && <Caption1 className={styles.description}>{description}</Caption1>}
</div>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
)
}
@@ -0,0 +1,139 @@
import { useRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
export interface SegmentOption<T extends string> {
value: T
label: string
/** optional leading icon (used by the sidebar theme switch) */
icon?: React.JSX.Element
}
const useStyles = makeStyles({
group: {
display: 'inline-flex',
width: 'fit-content',
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
overflow: 'hidden'
},
// Full-width variant: segments split the available width (the sidebar style).
fitted: {
display: 'flex',
width: '100%'
},
segment: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
padding: '7px 16px',
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
segmentFitted: {
flexGrow: 1,
flexBasis: 0,
padding: '7px 4px',
fontSize: tokens.fontSizeBase200
},
segmentActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
},
icon: {
display: 'flex',
fontSize: '16px',
flexShrink: 0
}
})
/**
* One shared segmented control (UI14) replacing the two hand-rolled versions
* the DownloadBar's Video/Audio kind toggle and the Sidebar's Light/Dark/Auto
* theme switch. Renders an ARIA radiogroup with roving-tabindex arrow-key
* navigation (UI30) and the shared focus ring (UI29). Active treatment is solid
* brand (matching both former implementations). `fitted` makes the segments split
* the full width (sidebar); the default is fit-content (download bar).
*/
export function SegmentedControl<T extends string>({
value,
options,
onChange,
ariaLabel,
fitted = false
}: {
value: T
options: SegmentOption<T>[]
onChange: (value: T) => void
ariaLabel: string
fitted?: boolean
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const refs = useRef<(HTMLButtonElement | null)[]>([])
function onKeyDown(e: React.KeyboardEvent, index: number): void {
let next = -1
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % options.length
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
next = (index - 1 + options.length) % options.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = options.length - 1
else return
const opt = options[next]
if (!opt) return
e.preventDefault()
onChange(opt.value)
refs.current[next]?.focus()
}
return (
<div
className={mergeClasses(styles.group, fitted && styles.fitted)}
role="radiogroup"
aria-label={ariaLabel}
>
{options.map((opt, i) => {
const on = opt.value === value
return (
<button
key={opt.value}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
// Roving tabindex: only the selected segment is a tab stop; arrow keys
// move within the group, as a radiogroup is expected to behave.
tabIndex={on ? 0 : -1}
className={mergeClasses(
styles.segment,
fitted && styles.segmentFitted,
on && styles.segmentActive,
focus.focusRing
)}
onClick={() => onChange(opt.value)}
onKeyDown={(e) => onKeyDown(e, i)}
>
{opt.icon && <span className={styles.icon}>{opt.icon}</span>}
{opt.label}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,35 @@
import { Badge } from '@fluentui/react-components'
import type { DownloadStatus } from '../../store/downloads'
/** The item-download status shown as a chip the queue's live status plus the
* library's 'pending' (catalogued but not yet downloaded). */
export type ChipStatus = DownloadStatus | 'pending'
type ChipColor = 'brand' | 'success' | 'danger' | 'warning' | 'subtle'
/**
* One label + color per status (UI18 / M8) the single source of truth that
* replaces QueueItem's Fluent `Badge` map and LibraryView's custom color `pill`
* spans, so the same status concept looks and reads identically on both screens
* (e.g. Library no longer says "Downloaded" where the queue says "Completed").
*/
const STATUS_CHIP: Record<ChipStatus, { label: string; color: ChipColor }> = {
pending: { label: 'Pending', color: 'subtle' },
queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' },
paused: { label: 'Paused', color: 'warning' },
saved: { label: 'Saved', color: 'subtle' },
completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'warning' }
}
/** Shared status chip used by the download queue and the library item list. */
export function StatusChip({ status }: { status: ChipStatus }): React.JSX.Element {
const { label, color } = STATUS_CHIP[status]
return (
<Badge appearance="tint" color={color}>
{label}
</Badge>
)
}
@@ -0,0 +1,21 @@
import { makeStyles, tokens } from '@fluentui/react-components'
/**
* Shared error-text styling (M12). The red `colorPaletteRedForeground1` was being
* applied via inline `style={{ color: … }}` across the app; this hook is the one
* place that owns it.
*
* - `error` a single line of error copy (e.g. an inline validation/result message).
* - `errorPre` the same colour but preserving newlines/long tokens, for multi-line
* yt-dlp/updater error output that would otherwise overflow.
*/
export const useErrorTextStyles = makeStyles({
error: {
color: tokens.colorPaletteRedForeground1
},
errorPre: {
color: tokens.colorPaletteRedForeground1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
})
@@ -0,0 +1,26 @@
import { makeStyles, tokens } from '@fluentui/react-components'
/**
* One focus-visible ring for every hand-rolled interactive element (UI29).
*
* Fluent's own controls draw their own focus indicator; the app's bespoke
* buttons, segmented controls, command-palette rows, and `role="button"` headers
* previously fell back to the UA default (often invisible) or removed it
* entirely. This gives them all the same visible, theme-aware keyboard focus.
*
* Apply with `mergeClasses(focus.focusRing, …)`. The ring is inset (negative
* offset) so it never clips inside `overflow: hidden` containers and follows each
* element's own border-radius; it shows only for `:focus-visible`, so pointer
* clicks stay quiet.
*/
export const useFocusStyles = makeStyles({
focusRing: {
outlineStyle: 'none',
':focus-visible': {
outlineWidth: '2px',
outlineStyle: 'solid',
outlineColor: tokens.colorStrokeFocus2,
outlineOffset: '-2px'
}
}
})
+38
View File
@@ -0,0 +1,38 @@
// One home for the renderer's date/time formatters (M9). These were previously
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
/** Relative "time since" label, e.g. "just now" / "5 min ago" / "3 h ago" /
* "2 d ago". Returns "never" for a missing timestamp. (Library last-indexed.) */
export function relTime(ms?: number): string {
if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago`
return `${Math.round(hrs / 24)} d ago`
}
/** Absolute "when" label that stays short for recent items: "Today, 3:04 PM" /
* "Yesterday, 3:04 PM" / "Jun 5, 2025". (History rows.) */
export function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
/** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM".
* Returns '' if the timestamp can't be formatted. (Queue scheduled badge.) */
export function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}
+14
View File
@@ -0,0 +1,14 @@
// Single id generator for queue items, terminal runs, and command templates.
// Replaces three near-identical `newId()` copies that had divergent fallback
// prefixes and could collide within a millisecond (M7 / L33 / L70).
//
// `crypto.randomUUID` is the real path in Electron/Node; the counter-suffixed
// fallback is effectively unreachable but kept so the function is total in any
// host, and the monotonic counter makes two ids minted in the same millisecond
// distinct (the old `Date.now()`-only fallbacks could collide).
let counter = 0
export function newId(prefix = 'id'): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `${prefix}-${Date.now()}-${++counter}`
}
+55 -12
View File
@@ -3,6 +3,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
import App from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
// injected. Stub the full surface so the UI renders and stays interactive during
@@ -18,7 +19,7 @@ if (import.meta.env.DEV && !window.api) {
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
@@ -75,7 +76,10 @@ if (import.meta.env.DEV && !window.api) {
runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
}),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -108,10 +112,38 @@ if (import.meta.env.DEV && !window.api) {
thumbnail: undefined,
formats: [
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
{ id: '299', label: '1080p60 · mp4 · 248 MB', height: 1080, ext: 'mp4', filesizeLabel: '248 MB', hasAudio: false },
{ id: '136', label: '720p · mp4 · 124 MB', height: 720, ext: 'mp4', filesizeLabel: '124 MB', hasAudio: false },
{ id: '135', label: '480p · mp4 · 72 MB', height: 480, ext: 'mp4', filesizeLabel: '72 MB', hasAudio: false },
{ id: '134', label: '360p · mp4 · 38 MB', height: 360, ext: 'mp4', filesizeLabel: '38 MB', hasAudio: false }
{
id: '299',
label: '1080p60 · mp4 · 248 MB',
height: 1080,
ext: 'mp4',
filesizeLabel: '248 MB',
hasAudio: false
},
{
id: '136',
label: '720p · mp4 · 124 MB',
height: 720,
ext: 'mp4',
filesizeLabel: '124 MB',
hasAudio: false
},
{
id: '135',
label: '480p · mp4 · 72 MB',
height: 480,
ext: 'mp4',
filesizeLabel: '72 MB',
hasAudio: false
},
{
id: '134',
label: '360p · mp4 · 38 MB',
height: 360,
ext: 'mp4',
filesizeLabel: '38 MB',
hasAudio: false
}
]
}
}
@@ -119,7 +151,6 @@ if (import.meta.env.DEV && !window.api) {
startDownload: async () => ({ ok: true }),
cancelDownload: async () => {},
pauseDownload: async () => {},
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
chooseFolder: async () => null,
openPath: async () => '',
showInFolder: async () => {},
@@ -162,16 +193,25 @@ if (import.meta.env.DEV && !window.api) {
},
updateYtdlp: async (channel) => {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
return {
ok: true,
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
}
},
// No background updater in the browser preview — hand back an inert unsubscribe.
onYtdlpAutoUpdateStatus: () => () => {},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
exportBackup: async () => ({
ok: true,
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
}),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {},
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
getSystemTheme: async () => ({
shouldUseDarkColors: false,
shouldUseHighContrastColors: false
}),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {},
@@ -229,12 +269,15 @@ if (import.meta.env.DEV && !window.api) {
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {},
setTaskbarProgress: () => {}
setTaskbarProgress: () => {},
mintPoToken: async () => null
}
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
)
+13
View File
@@ -0,0 +1,13 @@
// Centralized handler for fire-and-forget IPC (and similar async) calls whose
// failure we want recorded but can't surface to the user — the optimistic store
// update has already happened, so there's nothing to roll back or prompt about.
// Replaces the swallowed `.catch(() => {})` sites the audit flagged (M29) with a
// single, consistent log format. `op` names the operation, e.g. 'addHistory'.
//
// Note: in production the DevTools console isn't reachable (the app menu is
// suppressed), so this is primarily a development/`--inspect` diagnostic until a
// persistent renderer log sink lands (CC8). It is still strictly better than
// discarding the error.
export function logError(op: string): (e: unknown) => void {
return (e) => console.error(`[AeroFetch] ${op} failed:`, e)
}
+93 -35
View File
@@ -1,19 +1,24 @@
import { create } from 'zustand'
import type { DownloadEvent, DownloadMeta, DownloadOptions, CollectionContext } from '@shared/ipc'
import {
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
type DownloadEvent,
type DownloadMeta,
type DownloadOptions,
type CollectionContext,
type MediaKind
} from '@shared/ipc'
import { useSettings } from './settings'
import { useHistory } from './history'
import { useSources } from './sources'
import { newId } from '../id'
export type MediaKind = 'video' | 'audio'
// Single-sourced from the IPC contract (L105) and re-exported so existing
// `import { MediaKind } from '../store/downloads'` sites keep working.
export type { MediaKind }
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'saved'
| 'completed'
| 'error'
| 'canceled'
'queued' | 'downloading' | 'paused' | 'saved' | 'completed' | 'error' | 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
@@ -37,6 +42,12 @@ export interface DownloadItem {
speed?: string
eta?: string
sizeLabel?: string
/** yt-dlp reported no total size — render the bar indeterminate, not 0% (L137) */
sizeUnknown?: boolean
/** true once the main download stream finished and yt-dlp is fetching the
* remaining stream / merging / post-processing the bar goes indeterminate
* instead of visibly restarting 0100% for the second stream (SR7) */
finishing?: boolean
filePath?: string
error?: string
/** exact format carried so retry re-downloads the same thing */
@@ -60,10 +71,18 @@ export interface DownloadItem {
mediaItemId?: string
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
video: ['Best available', '1080p', '720p', '480p', '360p'],
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
}
// `satisfies` (not an annotation) so the tuple element types survive: under
// noUncheckedIndexedAccess a `readonly string[]` index yields `string | undefined`,
// but the const tuples keep `QUALITY_OPTIONS[kind][0]` known-defined (L168).
export const QUALITY_OPTIONS = {
video: VIDEO_QUALITY_OPTIONS,
audio: AUDIO_QUALITY_OPTIONS
} satisfies Record<MediaKind, readonly string[]>
// Placeholder channel shown while metadata is still being fetched. Tracked as a
// constant so the meta/error/done handlers can recognise and clear it instead of
// letting it stick forever when a probe returns no channel (SR6).
const RESOLVING = 'Resolving…'
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
@@ -129,12 +148,6 @@ interface DownloadState {
applyEvent: (ev: DownloadEvent) => void
}
let idCounter = 0
function newId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `item-${++idCounter}`
}
/**
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
* so addFromUrl and addMany share it. If the caller already probed the URL, its
@@ -150,10 +163,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId(),
id: newId('item'),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
channel: opts?.channel ?? RESOLVING,
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
@@ -196,7 +209,7 @@ function fmtEta(seconds: number): string {
const seed: DownloadItem[] = PREVIEW
? [
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
@@ -210,7 +223,7 @@ const seed: DownloadItem[] = PREVIEW
sizeLabel: '512 MB'
},
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=abcd1234',
title: 'Lo-fi beats to code to (1 hour mix)',
channel: 'ChillStudio',
@@ -221,7 +234,7 @@ const seed: DownloadItem[] = PREVIEW
progress: 0
},
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=zzz9999',
title: 'How yt-dlp Works Under the Hood',
channel: 'Open Source Weekly',
@@ -234,7 +247,7 @@ const seed: DownloadItem[] = PREVIEW
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4'
},
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=err0r',
title: 'Private video',
channel: undefined,
@@ -376,7 +389,11 @@ export const useDownloads = create<DownloadState>((set, get) => {
addMany: (entries) => {
if (entries.length === 0) return
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
// The queue is a newest-first array and pump() promotes the highest-index
// (oldest) queued item first, so a batch must be stored last-entry-first for
// the playlist/channel to download 1→N instead of N→1 (M32). Entry 1 then
// sits at the bottom of the list — the same way repeated single adds stack.
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts)).reverse()
set((s) => ({ items: [...built, ...s.items] }))
pump()
},
@@ -385,7 +402,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speed: undefined,
eta: undefined
}
: i
)
}))
@@ -396,7 +420,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speed: undefined,
eta: undefined
}
: i
)
}))
@@ -435,11 +466,12 @@ export const useDownloads = create<DownloadState>((set, get) => {
// item first (oldest-first over a newest-first array) — picks it next.
set((s) => {
const idx = s.items.findIndex((i) => i.id === id)
if (idx < 0 || s.items[idx].status !== 'queued') return {}
if (idx < 0 || s.items[idx]?.status !== 'queued') return {}
const items = s.items.slice()
const [it] = items.splice(idx, 1)
if (!it) return {}
let after = -1
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
for (let k = 0; k < items.length; k++) if (items[k]?.status === 'queued') after = k
items.splice(after + 1, 0, it)
return { items }
})
@@ -526,21 +558,35 @@ export const useDownloads = create<DownloadState>((set, get) => {
if (i.id !== ev.id) return i
switch (ev.type) {
case 'meta':
// A late meta event can arrive between cancel() and the child's
// close — don't overwrite a canceled item's fields (B5; mirrors the
// canceled guard on the done/error cases below).
if (i.status === 'canceled') return i
return {
...i,
title: ev.meta.title ?? i.title,
channel: ev.meta.channel ?? i.channel,
// Clear the 'Resolving…' placeholder even when the probe returns
// no channel, so it can't stick forever (SR6); a real channel is
// always preserved.
channel: ev.meta.channel ?? (i.channel === RESOLVING ? undefined : i.channel),
durationLabel: ev.meta.durationLabel ?? i.durationLabel
}
case 'progress':
// Ignore late events once the item has left the active state.
if (i.status !== 'downloading' && i.status !== 'queued') return i
// Only a launched (downloading) item should take progress. Never
// promote a 'queued' item here — pump() owns the queued→downloading
// transition and the concurrency cap (L88); a stray progress event
// for a non-downloading item is ignored.
if (i.status !== 'downloading') return i
return {
...i,
status: 'downloading',
progress: ev.progress.progress,
// Main owns the latch and resends it on every tick, so reflecting
// it directly resets cleanly on a retry's fresh spawn (SR7).
finishing: ev.progress.finishing,
speed: ev.progress.speed,
eta: ev.progress.eta,
sizeUnknown: ev.progress.sizeUnknown,
sizeLabel: ev.progress.sizeLabel ?? i.sizeLabel
}
case 'done':
@@ -549,13 +595,23 @@ export const useDownloads = create<DownloadState>((set, get) => {
...i,
status: 'completed',
progress: 1,
finishing: false,
speed: undefined,
eta: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
filePath: ev.filePath ?? i.filePath
}
case 'error':
if (i.status === 'canceled') return i
return { ...i, status: 'error', speed: undefined, eta: undefined, error: ev.error }
return {
...i,
status: 'error',
finishing: false,
speed: undefined,
eta: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
error: ev.error
}
default:
return i
}
@@ -595,7 +651,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
if (
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
) {
st.promoteDueScheduled()
}
}, 15_000)
+2 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { ErrorLogEntry } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -28,7 +29,7 @@ export const useErrorLog = create<ErrorLogState>((set) => ({
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearErrorLog().catch(() => {})
if (!PREVIEW) window.api.clearErrorLog().catch(logError('clearErrorLog'))
}
}))
+5 -4
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { HistoryEntry } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -58,23 +59,23 @@ export const useHistory = create<HistoryState>((set, get) => ({
add: (entry) => {
set((s) => ({ entries: [entry, ...s.entries.filter((e) => e.id !== entry.id)] }))
if (!PREVIEW) window.api.addHistory(entry).catch(() => {})
if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory'))
},
remove: (id) => {
set((s) => ({ entries: s.entries.filter((e) => e.id !== id) }))
if (!PREVIEW) window.api.removeHistory(id).catch(() => {})
if (!PREVIEW) window.api.removeHistory(id).catch(logError('removeHistory'))
},
removeMany: (ids) => {
const remove = new Set(ids)
set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
if (!PREVIEW) window.api.removeManyHistory(ids).catch(() => {})
if (!PREVIEW) window.api.removeManyHistory(ids).catch(logError('removeManyHistory'))
},
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(() => {})
if (!PREVIEW) window.api.clearHistory().catch(logError('clearHistory'))
},
openFile: (id) => {
+2 -2
View File
@@ -13,10 +13,10 @@ function parseSpeed(s?: string): number {
if (!s) return 0
const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s)
if (!m) return 0
const n = parseFloat(m[1])
const n = parseFloat(m[1] ?? '')
if (!isFinite(n)) return 0
const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
return n * (mult[m[2].toUpperCase()] ?? 1)
return n * (mult[(m[2] ?? '').toUpperCase()] ?? 1)
}
function formatSpeed(bytesPerSec: number): string {
+27 -7
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -12,7 +13,9 @@ const FALLBACK: Settings = {
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
// Mirror main's DEFAULTS (SR1): follow the OS theme until real settings load,
// so there's no white-on-first-paint flash for a dark-mode user.
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
@@ -26,12 +29,12 @@ const FALLBACK: Settings = {
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
autoDownloadNew: false,
// True in preview so design work isn't blocked behind the welcome screen;
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
hasCompletedOnboarding: PREVIEW,
@@ -56,14 +59,31 @@ export const useSettings = create<SettingsState>((set, get) => ({
update: (partial) => {
set(partial) // optimistic local update
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
if (PREVIEW) return
// M34: reconcile with main's authoritative, validated result — for exactly the
// keys we changed — so a value main clamps, sanitizes, or rejects (e.g. an
// unsafe filenameTemplate, an out-of-range maxConcurrent, a malformed
// rateLimit) stops showing as accepted in the UI until restart.
window.api
.setSettings(partial)
.then((saved) => {
const reconciled = Object.fromEntries(
(Object.keys(partial) as (keyof Settings)[]).map((k) => [k, saved[k]])
) as Partial<Settings>
set(reconciled)
})
.catch(logError('setSettings'))
},
chooseDir: (target) => {
if (PREVIEW) return
window.api.chooseFolder().then((dir) => {
if (dir) get().update({ [target]: dir })
})
// Seed the OS picker with the folder this target currently points at (W5).
window.api
.chooseFolder(get()[target])
.then((dir) => {
if (dir) get().update({ [target]: dir })
})
.catch(logError('chooseFolder'))
},
clearDir: (target) => get().update({ [target]: '' })
+12 -7
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
import { useDownloads } from './downloads'
import { useSettings } from './settings'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -113,7 +114,7 @@ export const useSources = create<SourcesState>((set, get) => ({
window.api
.listSources()
.then((sources) => set({ sources }))
.catch(() => {})
.catch(logError('listSources'))
},
selectSource: (id) => {
@@ -123,7 +124,7 @@ export const useSources = create<SourcesState>((set, get) => ({
window.api
.listSourceItems(id)
.then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
.catch(() => {})
.catch(logError('listSourceItems'))
}
},
@@ -164,7 +165,8 @@ export const useSources = create<SourcesState>((set, get) => ({
const items = await window.api.listSourceItems(id)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
}
} catch {
} catch (e) {
logError('reindexSource')(e)
set({ indexing: { active: false } })
}
},
@@ -174,7 +176,7 @@ export const useSources = create<SourcesState>((set, get) => ({
sources: s.sources.filter((x) => x.id !== id),
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
}))
if (!PREVIEW) window.api.removeSource(id).catch(() => {})
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
},
enqueueItems: (sourceId, items) => {
@@ -220,12 +222,15 @@ export const useSources = create<SourcesState>((set, get) => ({
}
return changed ? { itemsBySource: next } : {}
})
if (!PREVIEW) window.api.markSourceItemDownloaded(itemId, filePath).catch(() => {})
if (!PREVIEW)
window.api
.markSourceItemDownloaded(itemId, filePath)
.catch(logError('markSourceItemDownloaded'))
},
setWatched: (id, watched) => {
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(() => {})
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(logError('setSourceWatched'))
},
syncWatched: async () => {
@@ -274,7 +279,7 @@ if (!PREVIEW) {
setTimeout(() => useSources.getState().syncWatched(), 1500)
}
})
.catch(() => {})
.catch(logError('startup listSources'))
window.api.onIndexProgress((p: IndexProgress) => {
useSources.setState({
indexing: {
+3 -2
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { CommandTemplate } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -24,12 +25,12 @@ export const useTemplates = create<TemplatesState>((set) => ({
save: (template) => {
set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
if (!PREVIEW) window.api.saveTemplate(template).catch(() => {})
if (!PREVIEW) window.api.saveTemplate(template).catch(logError('saveTemplate'))
},
remove: (id) => {
set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
if (!PREVIEW) window.api.removeTemplate(id).catch(() => {})
if (!PREVIEW) window.api.removeTemplate(id).catch(logError('removeTemplate'))
}
}))
+1 -1
View File
@@ -24,7 +24,7 @@ export function youtubeId(url: string | undefined): string | null {
const v = u.searchParams.get('v')
if (v) return v
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
if (m) return m[1]
if (m?.[1]) return m[1]
}
return null
}
+30 -2
View File
@@ -20,7 +20,6 @@ export const IpcChannels = {
downloadStart: 'download:start',
downloadCancel: 'download:cancel',
downloadPause: 'download:pause',
defaultFolder: 'download:default-folder',
chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path',
showInFolder: 'shell:show-in-folder',
@@ -52,6 +51,9 @@ export const IpcChannels = {
/** main → renderer push channel for OS theme/contrast changes (nativeTheme 'updated') */
systemThemeUpdate: 'system-theme:update',
openHighContrastSettings: 'shell:open-high-contrast-settings',
/** preload main: the contextBridge failed to expose the API; main shows a
* hard error instead of letting the renderer silently fall back to mock mode (M30) */
preloadBridgeFailure: 'preload:bridge-failure',
/** main renderer push channel: a URL handed to AeroFetch from outside the app
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
externalUrl: 'external-url',
@@ -78,7 +80,9 @@ export const IpcChannels = {
/** main → renderer push channel for live terminal output lines */
terminalOutput: 'terminal:output',
/** renderer → main: reflect overall queue progress on the Windows taskbar (Phase O) */
taskbarProgress: 'taskbar:progress'
taskbarProgress: 'taskbar:progress',
/** renderer → main: open a YouTube WebView and extract a PO token (Phase P) */
youtubePoTokenMint: 'youtube:po-token-mint'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -118,6 +122,14 @@ export type CookieBrowser = (typeof COOKIE_BROWSERS)[number]
export const ACCENT_COLORS = ['rose', 'coral', 'amber', 'teal'] as const
export type AccentColor = (typeof ACCENT_COLORS)[number]
/** Quality/format labels for the video download selector. */
export const VIDEO_QUALITY_OPTIONS = ['Best available', '1080p', '720p', '480p', '360p'] as const
export type VideoQuality = (typeof VIDEO_QUALITY_OPTIONS)[number]
/** Quality/format labels for the audio download selector. */
export const AUDIO_QUALITY_OPTIONS = ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps'] as const
export type AudioQuality = (typeof AUDIO_QUALITY_OPTIONS)[number]
/** UI color theme: an explicit mode, or 'system' to follow the OS preference. */
export type ThemeMode = 'light' | 'dark' | 'system'
@@ -177,6 +189,12 @@ export interface DownloadOptions {
splitChapters: boolean
/** embed metadata (title/artist/etc.) */
embedMetadata: boolean
/** override the title tag before embedding; blank = keep the extracted value */
metadataTitle?: string
/** override the artist tag before embedding (audio downloads) */
metadataArtist?: string
/** override the album tag before embedding (audio downloads) */
metadataAlbum?: string
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
embedThumbnail: boolean
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
@@ -452,6 +470,14 @@ export interface DownloadProgress {
eta?: string
/** human-readable total size, e.g. '512 MB' */
sizeLabel?: string
/** true when yt-dlp reports no total/estimated size (livestreams, some sites),
* so `progress` stays 0 the whole time the bar should go indeterminate
* instead of reading a frozen 0% (L137) */
sizeUnknown?: boolean
/** latched true once the first download stream has finished, so the remaining
* stream/merge/post-processing shows as an indeterminate "Finishing…" state
* rather than a second 0100% fill of the bar (SR7) */
finishing?: boolean
}
/** Discriminated union pushed on IpcChannels.downloadEvent. */
@@ -740,4 +766,6 @@ export interface TaskbarProgress {
fraction: number
/** taskbar bar mode: hidden, normal, or red (some failed) */
mode: 'none' | 'normal' | 'error'
/** active download count for the overlay badge; absent or 0 = clear badge */
badgeCount?: number
}