From f167c02946902d880bd1c9b43892d451abc9f7e6 Mon Sep 17 00:00:00 2001 From: Wayne Date: Sun, 28 Jun 2026 12:22:19 -0400 Subject: [PATCH 01/82] Background running for downloads/auto-download, library clipboard detect, updater auth + token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the window no longer kills in-progress downloads, the library gains the same copied-link suggestion the downloads tab has, and the in-app updater can now authenticate to a sign-in-required Gitea. Background / tray: - The window's close handler now hides to the tray (instead of quitting) whenever a download is in flight, even if "Keep running in the tray" is off — quitting was killing the spawned yt-dlp processes. A one-time notification explains the app is still running. (download.ts exposes hasActiveDownloads().) - tray.ts now falls back to an embedded icon when no build/icon.ico ships, so the tray actually appears — previously an empty icon meant the tray was skipped and a minimized window could be stranded with no way back. - New "Start with Windows" setting (launchAtStartup) wired to app.setLoginItemSettings, synced at startup and on toggle. Useful with auto-download so watched channels stay current in the background. - The "Keep running in the tray" hint now explains it also enables background auto-download of new uploads. Library clipboard detection: - Extracted the downloads tab's clipboard watcher into a shared useClipboardLink hook and used it in the library's add-source field, so a copied channel/playlist link is offered there too. Updater fix (works on a private / sign-in-required instance): - Added an optional updateToken setting. When set, the updater sends it as a Gitea Authorization header on the release check, the checksum fetch, and the installer download — so "Check for updates" works where anonymous access is blocked (the previous "could not reach the update server" case). Blank = anonymous, unchanged. No token is ever shipped; it's only ever sent to the host-pinned update host. Settings gains a masked "Update access token" field. Typecheck clean; 187 tests pass; electron-vite build clean. New UI verified in the browser preview (tray/startup toggles, token field, library copied-link banner). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 +- src/main/download.ts | 9 +++ src/main/index.ts | 43 +++++++++-- src/main/settings.ts | 28 +++++++- src/main/tray.ts | 17 ++++- src/main/updater.ts | 17 ++++- src/renderer/src/components/LibraryView.tsx | 48 ++++++++++++- src/renderer/src/components/SettingsView.tsx | 28 +++++++- src/renderer/src/main.tsx | 4 +- src/renderer/src/store/settings.ts | 4 +- src/renderer/src/useClipboardLink.ts | 76 ++++++++++++++++++++ src/shared/ipc.ts | 9 +++ 12 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/useClipboardLink.ts diff --git a/package-lock.json b/package-lock.json index 904574b..282ce1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aerofetch", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aerofetch", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@fluentui/react-components": "^9.74.1", diff --git a/src/main/download.ts b/src/main/download.ts index 48f424b..a61b834 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -43,6 +43,15 @@ interface ActiveDownload { const active = new Map() +/** + * 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 + * spawned processes) when the user closes the window mid-download. + */ +export function hasActiveDownloads(): boolean { + return active.size > 0 +} + // --- Formatting helpers (raw yt-dlp numbers → human strings) ---------------- function num(s?: string): number | undefined { diff --git a/src/main/index.ts b/src/main/index.ts index c5dc614..eb4255e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron' +import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron' import { join, resolve } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { @@ -15,9 +15,15 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp import { getFfmpegVersions } from './ffmpeg' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { probeMedia } from './probe' -import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download' +import { + startDownload, + cancelDownload, + pauseDownload, + previewCommand, + hasActiveDownloads +} from './download' import { runTerminal, cancelTerminal } from './terminal' -import { getSettings, setSettings, ensureMediaDirs } from './settings' +import { getSettings, setSettings, ensureMediaDirs, applyLaunchAtStartup } from './settings' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listTemplates, saveTemplate, removeTemplate } from './templates' import { setupPortableData } from './portable' @@ -95,6 +101,19 @@ function getSystemThemeInfo(): SystemThemeInfo { } } +// Tell the user (once per run) that closing the window left AeroFetch running so +// an in-progress download could finish — shown only when they haven't already +// opted into tray mode, so a window that "won't close" doesn't read as a bug. +let notifiedBackground = false +function notifyBackgroundOnce(): void { + if (notifiedBackground || !Notification.isSupported()) return + 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.' + }).show() +} + // Web permissions a download manager never needs. They're denied for the app // window as defence-in-depth (audit T6): even if the renderer were compromised // (e.g. XSS via remote video metadata) it can't open the camera/mic, read @@ -137,12 +156,20 @@ function createWindow(): void { else win.show() }) - // Minimize-to-tray: when enabled, closing the window hides it to the tray - // instead of quitting. A real quit (tray menu / before-quit) sets isQuitting(). + // Closing the window hides to the tray (instead of quitting) when either the + // user opted into background mode, OR a download is in flight — quitting would + // kill the spawned yt-dlp processes and lose the download. A real quit (tray + // menu / before-quit) sets isQuitting() so this lets the close through. win.on('close', (e) => { - if (getSettings().minimizeToTray && !isQuitting()) { + if (isQuitting()) return + const downloadsRunning = hasActiveDownloads() + if (getSettings().minimizeToTray || downloadsRunning) { e.preventDefault() win.hide() + // If we're only staying alive because a download is running (the user + // didn't opt into tray mode), tell them once — otherwise a window that + // won't close looks like a bug. + if (downloadsRunning && !getSettings().minimizeToTray) notifyBackgroundOnce() } }) @@ -371,6 +398,10 @@ if (isPrimaryInstance) { // exist from first launch (downloads are routed into them by kind). ensureMediaDirs() + // 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. + applyLaunchAtStartup(getSettings().launchAtStartup) + app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) diff --git a/src/main/settings.ts b/src/main/settings.ts index 96c2558..e5ef522 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -50,7 +50,23 @@ const DEFAULTS: Settings = { notifyOnComplete: true, autoDownloadNew: true, hasCompletedOnboarding: false, - minimizeToTray: false + minimizeToTray: false, + launchAtStartup: false, + updateToken: '' +} + +/** + * Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at + * startup and whenever the toggle changes. On Windows this writes/removes a + * per-user registry Run entry — no admin needed, matching the app's no-elevation + * stance. Best-effort: a failure here just means the toggle didn't take effect. + */ +export function applyLaunchAtStartup(enabled: boolean): void { + try { + app.setLoginItemSettings({ openAtLogin: enabled }) + } catch { + /* non-fatal — e.g. unsupported platform */ + } } /** Fixed path for the --download-archive file; not user-configurable. */ @@ -212,6 +228,12 @@ export function setSettings(partial: Partial): Settings { case 'minimizeToTray': if (typeof value === 'boolean') s.set(key, value) break + case 'launchAtStartup': + if (typeof value === 'boolean') { + s.set('launchAtStartup', value) + applyLaunchAtStartup(value) + } + break case 'ytdlpChannel': // Same allowlist that guards the `--update-to` flag (audit F1). if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value) @@ -262,6 +284,10 @@ export function setSettings(partial: Partial): Settings { case 'youtubePoToken': if (typeof value === 'string') s.set(key, value) break + case 'updateToken': + // A Gitea token has no spaces; trim and store as-is (like proxy creds). + if (typeof value === 'string') s.set('updateToken', value.trim()) + break } } return getSettings() diff --git a/src/main/tray.ts b/src/main/tray.ts index 1227fba..4545cfd 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -7,6 +7,16 @@ import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron' import { getAppIconPath } from './binaries' +// Fallback tray glyph (32×32 teal disc + white download arrow) used when no +// build/icon.ico is present. Without this the tray is skipped (an empty image +// makes an invisible/unclickable Windows tray entry), which would strand a +// minimized-to-tray window with no way back. Embedded as base64 so it needs no +// asset-bundling step. +const FALLBACK_TRAY_PNG = + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAl0lEQVR4nO3TQQ6AIAxEUU7giV17bd0i' + + 'aaDTmdqY0ISl/peirf11jvO6+/N5cHXKwlIIG6cQqngIoY5DiKy4C4G8aBwJohSArpIBmIgNKAWgDys' + + 'AL8QGlANmCHZc8dUW1HEYEEFA6/d+B6q4CVAhwnHkb2DiUwCDkMRRBHpccQsRnXB8RLCAULxHMAAqbm0j5b4VoPRg1jzWxpzrS/JA7QAAAABJRU5ErkJggg==' + let tray: Tray | null = null // True once the user has chosen to really quit (tray menu, or app.quit from the // updater) — lets the window 'close' handler tell "hide to tray" from "exit". @@ -31,9 +41,10 @@ function show(getWindow: () => BrowserWindow | null): void { /** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */ export function createTray(getWindow: () => BrowserWindow | null): void { if (tray) return - const icon = nativeImage.createFromPath(getAppIconPath()) - // Without a usable icon a Windows tray entry is invisible/unclickable, which is - // worse than no tray — so skip it rather than ship a dead tray. + // 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()) return tray = new Tray(icon) diff --git a/src/main/updater.ts b/src/main/updater.ts index 1b4e940..75d69aa 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -3,6 +3,7 @@ import { createWriteStream, type WriteStream } from 'fs' import { stat, unlink } from 'fs/promises' import { join, normalize, dirname } from 'path' import { createHash } from 'crypto' +import { getSettings } from './settings' import { IpcChannels, type AppUpdateInfo, @@ -10,6 +11,18 @@ import { type AppUpdateProgress } from '@shared/ipc' +/** + * Authorization header for the update host. Empty unless the user has set an + * updateToken in Settings — needed when the release repo is private or the Gitea + * instance requires sign-in for anonymous access (the default on this instance). + * Only ever sent to the host-pinned UPDATE_HOST (see isTrustedDownloadUrl), so a + * redirect can never leak the token to another origin. + */ +function authHeader(): Record { + const tok = getSettings().updateToken?.trim() + return tok ? { Authorization: `token ${tok}` } : {} +} + // --- Update source ----------------------------------------------------------- // The Gitea repo whose Releases host the AeroFetch installers. The updater reads // the repo's latest release over the public REST API and downloads the installer @@ -109,7 +122,7 @@ export async function checkForAppUpdate(): Promise { const timer = setTimeout(() => controller.abort(), 15_000) try { const res = await fetch(RELEASE_API, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', ...authHeader() }, signal: controller.signal }) if (!res.ok) { @@ -187,6 +200,7 @@ function fetchTrustedText( resolve(r) } const request = net.request({ url, redirect: 'manual' }) + for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v) const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs) request.on('redirect', (_s, _m, redirectUrl) => { if (!isTrustedDownloadUrl(redirectUrl)) { @@ -279,6 +293,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise { if (idle) clearTimeout(idle) diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 42ab3ea..b72c28a 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -25,11 +25,14 @@ import { AppsListRegular, VideoClipMultipleRegular, AlertRegular, - LibraryRegular + LibraryRegular, + LinkRegular, + DismissRegular } from '@fluentui/react-icons' import type { MediaItem, Source } from '@shared/ipc' import { useSources } from '../store/sources' import { useSettings } from '../store/settings' +import { useClipboardLink } from '../useClipboardLink' import { useDownloads, type DownloadStatus } from '../store/downloads' import { thumbUrl } from '../thumb' import { MediaThumb } from './MediaThumb' @@ -70,6 +73,22 @@ const useStyles = makeStyles({ sub: { color: tokens.colorNeutralForeground3 }, addRow: { display: 'flex', gap: '8px' }, addInput: { flexGrow: 1 }, + suggestion: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 8px 8px 12px', + backgroundColor: tokens.colorBrandBackground2, + color: tokens.colorBrandForeground2, + ...shorthands.borderRadius(tokens.borderRadiusLarge) + }, + suggestionText: { + flexGrow: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, toolbar: { display: 'flex', alignItems: 'center', @@ -252,6 +271,9 @@ export function LibraryView(): React.JSX.Element { const [url, setUrl] = useState('') const [error, setError] = useState(null) + // Offer a freshly-copied channel/playlist link, the same way the Downloads tab + // offers a copied video link. + const clip = useClipboardLink(url) const [selected, setSelected] = useState>(new Set()) const [batchNote, setBatchNote] = useState(null) const [syncNote, setSyncNote] = useState(null) @@ -515,6 +537,30 @@ export function LibraryView(): React.JSX.Element { + {clip.suggestion && ( +
+ + Use copied link? {clip.suggestion} + +
+ )} +
+ + update({ updateToken: d.value })} + contentBefore={} + /> + + {appUpd?.ok && appUpd.available && ( <> diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 81c4d4e..f793de3 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -39,7 +39,9 @@ if (import.meta.env.DEV && !window.api) { notifyOnComplete: true, autoDownloadNew: true, hasCompletedOnboarding: true, - minimizeToTray: false + minimizeToTray: false, + launchAtStartup: false, + updateToken: '' } // Stands in for the cookies.txt file's mtime — lets the Cookies card's // sign-in/clear flow be exercised in this browser-only preview. diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index d147810..0fec846 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -35,7 +35,9 @@ const FALLBACK: Settings = { // 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, - minimizeToTray: false + minimizeToTray: false, + launchAtStartup: false, + updateToken: '' } interface SettingsState extends Settings { diff --git a/src/renderer/src/useClipboardLink.ts b/src/renderer/src/useClipboardLink.ts new file mode 100644 index 0000000..5f46af6 --- /dev/null +++ b/src/renderer/src/useClipboardLink.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { useSettings } from './store/settings' + +/** A quick heuristic for "this clipboard text is a link worth offering". */ +export function looksLikeUrl(text: string): boolean { + const t = text.trim() + if (!/^https?:\/\//i.test(t)) return false + try { + new URL(t) + return true + } catch { + return false + } +} + +interface ClipboardLink { + /** the freshly-copied link being offered, or null */ + suggestion: string | null + /** accept the suggestion: clears it and returns the link (or null if none) */ + accept: () => string | null + /** dismiss the suggestion without using it */ + dismiss: () => void +} + +/** + * Watches the clipboard on window focus and offers a freshly-copied http(s) link. + * Shared by the download bar and the library's add-source field so both fields + * can auto-suggest a copied URL. Respects the `clipboardWatch` setting and never + * interrupts text the user is already typing — pass the field's current value as + * `currentValue` and the watcher stays quiet while it's non-empty. + */ +export function useClipboardLink(currentValue: string): ClipboardLink { + const [suggestion, setSuggestion] = useState(null) + const valueRef = useRef('') + valueRef.current = currentValue + // The last link we offered or that the user dismissed — so re-focusing doesn't + // keep re-offering the same one. + const lastSeen = useRef(null) + + useEffect(() => { + let active = true + async function check(): Promise { + if (!useSettings.getState().clipboardWatch || valueRef.current.trim()) return + let text = '' + try { + text = (await window.api.readClipboard()) ?? '' + } catch { + return + } + if (!active) return + text = text.trim() + if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text) + } + check() + window.addEventListener('focus', check) + return () => { + active = false + window.removeEventListener('focus', check) + } + }, []) + + return { + suggestion, + accept: () => { + if (!suggestion) return null + lastSeen.current = suggestion + const accepted = suggestion + setSuggestion(null) + return accepted + }, + dismiss: () => { + lastSeen.current = suggestion + setSuggestion(null) + } + } +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a6e6689..9f5e90f 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -525,6 +525,15 @@ export interface Settings { hasCompletedOnboarding: boolean /** keep AeroFetch running in the system tray when its window is closed (Phase O) */ minimizeToTray: boolean + /** launch AeroFetch automatically when the user signs in to Windows */ + launchAtStartup: boolean + /** + * Optional Gitea access token for the in-app updater. Empty = anonymous (works + * only where the release repo allows anonymous access). When the release repo + * is private / the instance requires sign-in, paste a read-only token so the + * updater can check + download. Stored locally in settings, never shipped. + */ + updateToken: string } /** From 5a9f2de3907e55df59b4827cc987d8559ff15394 Mon Sep 17 00:00:00 2001 From: debont80 Date: Mon, 29 Jun 2026 10:15:59 -0400 Subject: [PATCH 02/82] Encrypt credentials at rest; unify clipboard-link suggestions Encrypt proxy / youtubePoToken / updateToken on disk via safeStorage (DPAPI on Windows), with a one-time launch migration for legacy plaintext. Decrypted before reaching callers/renderer; backup export still writes clear, as documented. Harden updater token handling to refuse cross-origin redirects on the authenticated REST check. Extract the clipboard-link logic from DownloadBar into the shared useClipboardLink hook: add an optional filter (library skips single-video links), an offer() for external aerofetch:// / .url links, and a source field driving banner wording. Add looksLikeSingleVideo plus its unit tests. Co-Authored-By: Claude Opus 4.8 --- src/main/index.ts | 12 ++- src/main/settings.ts | 87 ++++++++++++++-- src/main/updater.ts | 12 ++- src/renderer/src/components/DownloadBar.tsx | 81 +++------------ src/renderer/src/components/LibraryView.tsx | 8 +- src/renderer/src/components/SettingsView.tsx | 2 +- src/renderer/src/useClipboardLink.ts | 101 +++++++++++++++---- test/clipboardLink.test.ts | 75 ++++++++++++++ 8 files changed, 278 insertions(+), 100 deletions(-) create mode 100644 test/clipboardLink.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index eb4255e..02b4dfe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,7 +23,13 @@ import { hasActiveDownloads } from './download' import { runTerminal, cancelTerminal } from './terminal' -import { getSettings, setSettings, ensureMediaDirs, applyLaunchAtStartup } from './settings' +import { + getSettings, + setSettings, + ensureMediaDirs, + applyLaunchAtStartup, + migrateSecretsAtRest +} from './settings' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listTemplates, saveTemplate, removeTemplate } from './templates' import { setupPortableData } from './portable' @@ -398,6 +404,10 @@ if (isPrimaryInstance) { // exist from first launch (downloads are routed into them by kind). ensureMediaDirs() + // Encrypt any credential still stored as legacy plaintext (from before at-rest + // encryption), once safeStorage is available post-ready. + migrateSecretsAtRest() + // 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. applyLaunchAtStartup(getSettings().launchAtStartup) diff --git a/src/main/settings.ts b/src/main/settings.ts index e5ef522..fa67807 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { app, safeStorage } from 'electron' import { join } from 'path' import { mkdirSync } from 'fs' import Store from 'electron-store' @@ -149,6 +149,72 @@ function getStore(): Store { return store } +// --- Credential encryption at rest ------------------------------------------ +// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API +// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows) +// so a leaked settings.json doesn't expose them. They're still decrypted before +// reaching the renderer and exported in clear by backup — this guards the file at +// rest only. +const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const + +// Marks a value produced by encryptSecret, so a read can tell ciphertext from a +// legacy plaintext value (written before encryption existed, or while safeStorage +// was unavailable) and migrate it on the next write. +const ENC_PREFIX = 'enc:v1:' + +/** Encrypt a secret for storage. Falls back to plaintext where safeStorage is unavailable. */ +function encryptSecret(plain: string): string { + if (!plain) return '' + try { + if (safeStorage.isEncryptionAvailable()) { + return ENC_PREFIX + safeStorage.encryptString(plain).toString('base64') + } + } catch { + /* fall through — store plaintext, as it was before encryption existed */ + } + return plain +} + +/** Decrypt a stored secret. Legacy plaintext is returned as-is; an undecryptable blob → ''. */ +function decryptSecret(stored: string): string { + if (!stored.startsWith(ENC_PREFIX)) return stored + try { + return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64')) + } catch { + // Different user/machine or a corrupt blob — drop it rather than surface + // ciphertext into the UI or onto a yt-dlp command line. + return '' + } +} + +/** A copy of settings with the credential fields decrypted for in-process use. */ +function withDecryptedSecrets(raw: Settings): Settings { + const out = { ...raw } + for (const key of SECRET_KEYS) out[key] = decryptSecret(raw[key] ?? '') + return out +} + +/** + * One-time (per launch) migration: re-store any credential still held as legacy + * plaintext as ciphertext. Lets settings files written before at-rest encryption + * get protected without a write on the hot getSettings() path. No-op when there's + * nothing to migrate or safeStorage is unavailable. + */ +export function migrateSecretsAtRest(): void { + let available = false + try { + available = safeStorage.isEncryptionAvailable() + } catch { + return + } + if (!available) return + const s = getStore() + for (const key of SECRET_KEYS) { + const raw = s.get(key) ?? '' + if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw)) + } +} + export function getSettings(): Settings { const s = getStore() // getSettings() is on hot paths (buildCommand, notification checks, the system- @@ -170,7 +236,9 @@ export function getSettings(): Settings { if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) { s.set('accentColor', DEFAULTS.accentColor) } - return s.store + // Hand callers (and, via IPC, the renderer) plaintext credentials — they're + // only encrypted on disk (see withDecryptedSecrets / encryptSecret). + return withDecryptedSecrets(s.store) } /** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */ @@ -276,17 +344,20 @@ export function setSettings(partial: Partial): Settings { break case 'defaultVideoQuality': case 'defaultAudioQuality': - // proxy may carry plaintext credentials (user:pass@host); they are stored - // and exported by exportBackup as-is — documented, not masked. - case 'proxy': case 'rateLimit': case 'youtubePlayerClient': - case 'youtubePoToken': if (typeof value === 'string') s.set(key, value) break + // Credential-bearing fields — encrypted at rest (see encryptSecret). proxy + // may embed user:pass@host; youtubePoToken is an access token. exportBackup + // still writes them in clear (via the decrypted getSettings), as documented. + case 'proxy': + case 'youtubePoToken': + if (typeof value === 'string') s.set(key, encryptSecret(value)) + break case 'updateToken': - // A Gitea token has no spaces; trim and store as-is (like proxy creds). - if (typeof value === 'string') s.set('updateToken', value.trim()) + // A Gitea token has no spaces; trim before encrypting (like proxy creds). + if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim())) break } } diff --git a/src/main/updater.ts b/src/main/updater.ts index 75d69aa..c744d0a 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -15,8 +15,12 @@ import { * Authorization header for the update host. Empty unless the user has set an * updateToken in Settings — needed when the release repo is private or the Gitea * instance requires sign-in for anonymous access (the default on this instance). - * Only ever sent to the host-pinned UPDATE_HOST (see isTrustedDownloadUrl), so a - * redirect can never leak the token to another origin. + * + * The token must never reach an origin other than the host-pinned UPDATE_HOST. + * Every request that carries it guards against that on its own terms: the REST + * check refuses to follow redirects (`redirect: 'error'`), and the download paths + * use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So + * a redirect can't bounce the header to another host on any path. */ function authHeader(): Record { const tok = getSettings().updateToken?.trim() @@ -123,6 +127,10 @@ export async function checkForAppUpdate(): Promise { try { const res = await fetch(RELEASE_API, { headers: { Accept: 'application/json', ...authHeader() }, + // The API lives at an exact pinned URL and answers 200 directly. Refusing + // redirects keeps an authenticated check from ever forwarding the token to + // another origin; a stray redirect fails safe into the catch below. + redirect: 'error', signal: controller.signal }) if (!res.ok) { diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 9fea3e1..5932909 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -33,18 +33,7 @@ import { sameVideo } from '../store/queueStats' import { useSettings } from '../store/settings' import { Select } from './Select' import { Hint } from './Hint' - -/** A quick heuristic for "this clipboard text is a link worth offering". */ -function looksLikeUrl(text: string): boolean { - const t = text.trim() - if (!/^https?:\/\//i.test(t)) return false - try { - new URL(t) - return true - } catch { - return false - } -} +import { useClipboardLink, looksLikeUrl } from '../useClipboardLink' /** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */ function firstUrl(text: string): string | null { @@ -384,62 +373,22 @@ export function DownloadBar(): React.JSX.Element { // bar's global kind. Lets a playlist mix video and audio downloads. const [itemKinds, setItemKinds] = useState>({}) - // Clipboard auto-detect: when the window gains focus and the clipboard holds a - // fresh link, offer it (without clobbering anything the user is already typing). - // The same banner also surfaces links handed to AeroFetch from outside — the - // aerofetch:// protocol or a "Send to" .url file (see onExternalUrl below). - const [suggestion, setSuggestion] = useState(null) - const [suggestionSource, setSuggestionSource] = useState<'clipboard' | 'external'>('clipboard') - const urlRef = useRef('') - urlRef.current = url - const lastSeen = useRef(null) - - useEffect(() => { - let active = true - async function check(): Promise { - if (!useSettings.getState().clipboardWatch || urlRef.current.trim()) return - let text = '' - try { - text = (await window.api.readClipboard()) ?? '' - } catch { - return - } - if (!active) return - text = text.trim() - if (looksLikeUrl(text) && text !== lastSeen.current) { - setSuggestionSource('clipboard') - setSuggestion(text) - } - } - check() - window.addEventListener('focus', check) - return () => { - active = false - window.removeEventListener('focus', check) - } - }, []) - - // A link handed in from outside always takes priority over whatever the - // clipboard banner was showing — it's a direct request, not a guess. - useEffect( - () => - window.api.onExternalUrl((incomingUrl) => { - setSuggestionSource('external') - setSuggestion(incomingUrl) - }), - [] - ) + // Clipboard auto-detect and links handed to AeroFetch from outside (the + // aerofetch:// protocol or a "Send to" .url file) share one suggestion banner, + // driven by the same hook the library's add-source field uses. An external link + // always takes priority over a clipboard guess — it's a direct request. + const { + suggestion, + source: suggestionSource, + accept: acceptLink, + dismiss: dismissSuggestion, + offer: offerLink + } = useClipboardLink(url) + useEffect(() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')), [offerLink]) function acceptSuggestion(): void { - if (!suggestion) return - lastSeen.current = suggestion - onUrlChange(suggestion) - setSuggestion(null) - } - - function dismissSuggestion(): void { - lastSeen.current = suggestion - setSuggestion(null) + const link = acceptLink() + if (link) onUrlChange(link) } const usingFormats = kind === 'video' && info !== null && info.formats.length > 0 diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index b72c28a..929b9ee 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -32,7 +32,7 @@ import { import type { MediaItem, Source } from '@shared/ipc' import { useSources } from '../store/sources' import { useSettings } from '../store/settings' -import { useClipboardLink } from '../useClipboardLink' +import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' import { useDownloads, type DownloadStatus } from '../store/downloads' import { thumbUrl } from '../thumb' import { MediaThumb } from './MediaThumb' @@ -271,9 +271,9 @@ export function LibraryView(): React.JSX.Element { const [url, setUrl] = useState('') const [error, setError] = useState(null) - // Offer a freshly-copied channel/playlist link, the same way the Downloads tab - // offers a copied video link. - const clip = useClipboardLink(url) + // 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)) const [selected, setSelected] = useState>(new Set()) const [batchNote, setBatchNote] = useState(null) const [syncNote, setSyncNote] = useState(null) diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index cc01dd8..d7bae13 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -968,7 +968,7 @@ export function SettingsView(): React.JSX.Element { ` with no + * `list=` context), so a channel/playlist link — on YouTube or any other site — is + * never wrongly rejected. A `list=` param means "add the playlist", so it's kept. + */ +export function looksLikeSingleVideo(text: string): boolean { + let u: URL + try { + u = new URL(text.trim()) + } catch { + return false + } + if (u.searchParams.has('list')) return false + const host = u.hostname.replace(/^www\./, '').toLowerCase() + if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') { + return u.pathname === '/watch' && u.searchParams.has('v') + } + if (host === 'youtu.be') { + return /^\/[\w-]{6,}$/.test(u.pathname) + } + return false +} + +/** Where an offered link came from — drives the banner's wording. */ +export type SuggestionSource = 'clipboard' | 'external' + interface ClipboardLink { - /** the freshly-copied link being offered, or null */ + /** the link currently being offered, or null */ suggestion: string | null + /** where `suggestion` came from: a clipboard guess vs. a link handed in from outside */ + source: SuggestionSource /** accept the suggestion: clears it and returns the link (or null if none) */ accept: () => string | null /** dismiss the suggestion without using it */ dismiss: () => void + /** + * Offer a link from outside the clipboard — e.g. the aerofetch:// protocol or a + * "Send to" .url file. Always takes priority over a clipboard guess and is shown + * even when clipboard-watch is off (it's a direct request, not a guess) and even + * while the field has text (the banner only fills the field once accepted). + * Stable identity, so it's safe to use as an effect dependency. + */ + offer: (url: string, source?: SuggestionSource) => void } /** @@ -28,14 +67,31 @@ interface ClipboardLink { * can auto-suggest a copied URL. Respects the `clipboardWatch` setting and never * interrupts text the user is already typing — pass the field's current value as * `currentValue` and the watcher stays quiet while it's non-empty. + * + * Pass `filter` to narrow what the clipboard offers — e.g. the library skips + * single-video links so it only suggests channels/playlists. It gates only the + * clipboard guess; links pushed via `offer()` bypass it (and the other guards), + * since they're explicit requests rather than guesses. */ -export function useClipboardLink(currentValue: string): ClipboardLink { +export function useClipboardLink( + currentValue: string, + filter?: (url: string) => boolean +): ClipboardLink { const [suggestion, setSuggestion] = useState(null) + const [source, setSource] = useState('clipboard') const valueRef = useRef('') valueRef.current = currentValue + // Held in a ref so an inline `filter` doesn't have to be memoized to keep the + // focus listener from re-subscribing every render. + const filterRef = useRef(filter) + filterRef.current = filter // The last link we offered or that the user dismissed — so re-focusing doesn't // keep re-offering the same one. const lastSeen = useRef(null) + // Mirror the live suggestion so accept/dismiss can read it without depending on + // it — keeps their identity stable across renders. + const suggestionRef = useRef(null) + suggestionRef.current = suggestion useEffect(() => { let active = true @@ -49,7 +105,11 @@ export function useClipboardLink(currentValue: string): ClipboardLink { } if (!active) return text = text.trim() - if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text) + const wanted = looksLikeUrl(text) && (filterRef.current?.(text) ?? true) + if (wanted && text !== lastSeen.current) { + setSource('clipboard') + setSuggestion(text) + } } check() window.addEventListener('focus', check) @@ -59,18 +119,23 @@ export function useClipboardLink(currentValue: string): ClipboardLink { } }, []) - return { - suggestion, - accept: () => { - if (!suggestion) return null - lastSeen.current = suggestion - const accepted = suggestion - setSuggestion(null) - return accepted - }, - dismiss: () => { - lastSeen.current = suggestion - setSuggestion(null) - } - } + const accept = useCallback((): string | null => { + const link = suggestionRef.current + if (!link) return null + lastSeen.current = link + setSuggestion(null) + return link + }, []) + + const dismiss = useCallback((): void => { + lastSeen.current = suggestionRef.current + setSuggestion(null) + }, []) + + const offer = useCallback((url: string, src: SuggestionSource = 'external'): void => { + setSource(src) + setSuggestion(url) + }, []) + + return { suggestion, source, accept, dismiss, offer } } diff --git a/test/clipboardLink.test.ts b/test/clipboardLink.test.ts new file mode 100644 index 0000000..047e014 --- /dev/null +++ b/test/clipboardLink.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { looksLikeUrl, looksLikeSingleVideo } from '../src/renderer/src/useClipboardLink' + +describe('looksLikeUrl', () => { + it('accepts http(s) URLs', () => { + expect(looksLikeUrl('https://youtube.com/watch?v=abc')).toBe(true) + expect(looksLikeUrl('http://example.com')).toBe(true) + expect(looksLikeUrl('https://example.com/a/b?c=d#frag')).toBe(true) + }) + + it('is scheme-case-insensitive', () => { + expect(looksLikeUrl('HTTPS://example.com')).toBe(true) + expect(looksLikeUrl('HtTp://example.com')).toBe(true) + }) + + it('trims surrounding whitespace', () => { + expect(looksLikeUrl(' https://example.com\n')).toBe(true) + expect(looksLikeUrl('\t http://example.com \t')).toBe(true) + }) + + it('rejects non-http(s) schemes', () => { + expect(looksLikeUrl('ftp://example.com')).toBe(false) + expect(looksLikeUrl('file:///c:/x')).toBe(false) + expect(looksLikeUrl('magnet:?xt=urn:btih:abc')).toBe(false) + expect(looksLikeUrl('javascript:alert(1)')).toBe(false) + expect(looksLikeUrl('mailto:a@b.com')).toBe(false) + }) + + it('rejects bare domains and scheme-less text', () => { + expect(looksLikeUrl('www.example.com')).toBe(false) + expect(looksLikeUrl('example.com/watch')).toBe(false) + expect(looksLikeUrl('see http://example.com')).toBe(false) // must start with the scheme + }) + + it('rejects empty, whitespace, and malformed input', () => { + expect(looksLikeUrl('')).toBe(false) + expect(looksLikeUrl(' ')).toBe(false) + expect(looksLikeUrl('not a link')).toBe(false) + expect(looksLikeUrl('http://')).toBe(false) // passes the prefix test but is not a valid URL + }) +}) + +describe('looksLikeSingleVideo', () => { + it('flags YouTube single-video URLs', () => { + expect(looksLikeSingleVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true) + expect(looksLikeSingleVideo('https://youtube.com/watch?v=abc123')).toBe(true) + expect(looksLikeSingleVideo('https://m.youtube.com/watch?v=abc123')).toBe(true) + expect(looksLikeSingleVideo('https://music.youtube.com/watch?v=abc123')).toBe(true) + expect(looksLikeSingleVideo('https://youtu.be/dQw4w9WgXcQ')).toBe(true) + }) + + it('keeps anything with a playlist context (?list=)', () => { + // A video opened in a playlist → the user likely wants the whole playlist. + expect(looksLikeSingleVideo('https://www.youtube.com/watch?v=abc&list=PL123')).toBe(false) + expect(looksLikeSingleVideo('https://youtu.be/abc?list=PL123')).toBe(false) + }) + + it('keeps channels and playlists', () => { + expect(looksLikeSingleVideo('https://www.youtube.com/@SomeChannel')).toBe(false) + expect(looksLikeSingleVideo('https://www.youtube.com/channel/UC123')).toBe(false) + expect(looksLikeSingleVideo('https://www.youtube.com/c/SomeChannel')).toBe(false) + expect(looksLikeSingleVideo('https://www.youtube.com/playlist?list=PL123')).toBe(false) + }) + + it('does not flag single videos on other sites (conservative)', () => { + // We can't reliably classify arbitrary hosts, so never reject them. + expect(looksLikeSingleVideo('https://vimeo.com/123456789')).toBe(false) + expect(looksLikeSingleVideo('https://example.com/watch?v=abc')).toBe(false) + }) + + it('returns false for non-URLs', () => { + expect(looksLikeSingleVideo('not a url')).toBe(false) + expect(looksLikeSingleVideo('')).toBe(false) + }) +}) From a6a8c5f57843f29f64acf2e4aa61d3a36f9d2544 Mon Sep 17 00:00:00 2001 From: debont80 Date: Mon, 29 Jun 2026 13:47:45 -0400 Subject: [PATCH 03/82] Update CODE-AUDIT.md: living checklist with stable IDs Reformat from a one-off security report into a living checklist. Adds the 2026-06-29 architectural review findings and a second polish pass; all original security findings marked completed. Items carry stable IDs for tracking across sessions. Co-Authored-By: Claude Sonnet 4.6 --- CODE-AUDIT.md | 1636 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 1350 insertions(+), 286 deletions(-) diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 43bd463..1402e43 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -1,342 +1,1406 @@ -# AeroFetch Code Audit +# Code Audit -**Date:** 2026-06-23 -**Scope:** Full security & correctness review of main process, preload, IPC contract, and renderer stores -**Overall:** Strong security fundamentals (context isolation, sandboxing, argument-injection defense); findings are refinements, not foundational gaps +Living checklist of audit findings for AeroFetch. Security & correctness were reviewed +2026-06-23 (all fixed — see Completed). The architectural review (2026-06-29) added the +structural items; a second polish pass (2026-06-29) added the smaller inconsistencies. +Items carry stable IDs so we can check them off as they land this session. + +Severity = structural leverage / risk of future bugs, not "app is broken" (it isn't). --- -## Executive Summary +# 1.0 Release-Readiness Audit (lead-engineer synthesis) -AeroFetch's security posture is notably thoughtful: +*A pre-release synthesis of the ~385 catalogued findings below — root causes, a release gate, and the +consequential issues per dimension (severity · reasoning · fix · benefit). The detailed, ID'd backlog +follows. No rewrite required; everything here is incremental.* -- **Layered argument-injection defense**: `assertHttpUrl` validators + `--` terminator before the URL, preventing yt-dlp flag injection -- **Tight process model**: `contextIsolation: true`, `sandbox: true`, `nodeIntegration: false`; thin preload forwarding only typed IPC -- **File opening safety**: [reveal.ts](src/main/reveal.ts) allowlist restricts to media extensions, blocks `.exe`/`.bat`/`.ps1` -- **Settings validation**: Type-checked before persistence; `spawn` used without `shell: true` -- **Good test coverage** of risky logic (especially [buildArgs.test.ts](test/buildArgs.test.ts)) +## Verdict -The findings below are opportunities to tighten existing defenses and fix performance gaps. None are foundational breaks — with one exception added later: **C1**, a missing bundled `ffprobe.exe` that broke duration-dependent post-processing, surfaced by the real-download smoke test and now fixed. +**Not 1.0-ready as-is, but close — roughly 3–4 focused PRs away.** The foundation is genuinely strong: +disciplined security model (context isolation, sandbox, argv-injection defense, host-pinned signed-checksum +updater), a clean pure/impure architecture, and good unit coverage of the pure logic. What blocks a +*commercial* 1.0 is a thin layer of (a) real reliability bugs, (b) naive persistence, (c) built-but-unwired +features, (d) first-run/perceived-quality gaps, and (e) the absence of enforcement/observability tooling. +None require rearchitecting. ---- +## Root-cause themes (the 385 findings collapse to 8) -## Findings +1. **Built-but-unwired features** — command preview (M5), incognito (M6), per-download options/extraArgs + (UX1) are plumbed end-to-end with no UI; the ROADMAP even marks them ✅ (M25). *Decide wire-or-cut.* +2. **"Two ways to do X" with no enforcement** — persistence (M1), validation (CC9), async (CC5), errors + (CC6), UI primitives (UI14/UI18/UI19), formatters (M9) — and no ESLint/Prettier to hold any line (CC2). +3. **Renderer-optimistic state never reconciles + a store cycle** — M34/L142 (settings show unsaved + values), C2 (downloads↔sources circular import). +4. **Naive JSON persistence** — non-atomic writes (R1), corrupt→silent total data loss (R2), O(n²) + full-file rewrites per completion (R3/PERF7), no cache. +5. **Download-engine reliability gaps** — no stall timeout (B1), reverse-order batches (M32), orphan + `.part` on cancel (R4), retry-during-teardown race (L140). +6. **First-run / perceived quality** — light-default theme (SR1), nightly-default yt-dlp (SR2), placeholder + icon (W14), stuck "Resolving…" (SR6), progress bar visibly restarting (SR7), unsigned build (SIGNING.md). +7. **Accessibility & Windows-native feel** — focus rings (UI28/29), no list keyboard nav (W7), title-bar + theme (W3), text-field context menu (W4), no `aria-live` (W17), input labels (M28). +8. **No enforcement / observability** — no lint (CC2), `noImplicitAny:false` (M38), no logging (CC8/M29), + no production source maps (L170). -### Security +## Release gate -#### S1 — Backup import enables arbitrary custom-command templates silently +**MUST fix before 1.0 (blockers)** — correctness, data safety, security, trust: +B1 · M32 · M35 · M34 · R1 · R2 · R3 · H7 · H8 · code-signing · SR1 · SR6 · SR7 · W14. -**File:** [backup.ts:54-55](src/main/backup.ts) -**Severity:** Medium -**Status:** Fixed — 2026-06-23 +**SHOULD fix for 1.0 (high):** wire-or-cut the dead features (M5/M6/UX1) · a11y cluster (UI28/29, W4, W7, +W17, M28) · W3 native theming · lint + `noImplicitAny` (CC2/M38) · dev-jargon copy (M37/SR9) · destructive +confirmations (UX4) · PERF1/PERF2 (per-download redundant work). -**Description:** -`importBackup` restores settings + templates from a user-chosen JSON file. The code validates only that `CommandTemplate` fields have the right shape (id, name, args), then immediately applies them via `setSettings`. If the backup contains `customCommandEnabled: true` + `defaultTemplateId` pointing to a template with `args: "--exec 'cmd'"`, the next download will spawn arbitrary code with no user confirmation that the file carried custom commands. +**DEFER to 1.x:** the ~150 Low items · the simplification refactors (SIMP*, do incrementally) · the UI +token system + shared primitives (UI/SIMP — high value, larger effort) · i18n · sqlite migration. -**Why it matters:** -An attacker-supplied backup file smuggles code execution into an otherwise innocuous "import my settings" gesture. Unlike direct UI creation of templates, there's no moment where the user sees what's being enabled. +## By dimension (severity · reasoning · fix · benefit) -**Fix:** -Surface the count of imported custom commands and require explicit confirmation before applying. Consider importing templates in a disabled state. Alternative: show a preview of template names/args (first 100 chars) before committing. - ---- - -#### S2 — Cookie login window allows popups of arbitrary schemes - -**File:** [cookies.ts:119-125](src/main/cookies.ts) -**Severity:** Low -**Status:** Fixed — 2026-06-23 - -**Description:** -The login window's `setWindowOpenHandler` returns `action: 'allow'` for every popup without protocol filtering. A logged-in webpage could spawn windows with `file://`, custom protocols, or other non-HTTP schemes in the shared session partition. The main window [index.ts:118-126](src/main/index.ts) correctly restricts to `http:`/`https:` only. - -**Why it matters:** -Defense-in-depth: the sandbox limits damage, but an attacker page could open a `file://` popup to exfil cookies to disk, or use a custom-protocol popup as a pivot. - -**Fix:** -Apply the same protocol check to login popups: -```typescript -win.webContents.setWindowOpenHandler((details) => { - try { - const { protocol } = new URL(details.url) - if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' } - } catch { return { action: 'deny' } } - return { action: 'allow', ... } -}) -``` - ---- - -#### S3 — Concurrency cap is renderer-only (defense-in-depth gap) - -**File:** [downloads.ts:249-265](src/renderer/src/store/downloads.ts) -**Severity:** Low -**Status:** Fixed — 2026-06-23 - -**Description:** -`maxConcurrent` is enforced solely by the renderer's `pump()` function. `startDownload` in main ([download.ts:214](src/main/download.ts)) only checks for duplicate IDs, not active count. A buggy or compromised renderer could spawn unbounded yt-dlp processes. - -**Why it matters:** -Defense-in-depth: the current design assumes the renderer is trusted. If it ever leaks (XSS in an injected thumbnail URL, future bundled library), the main process should still cap spawns. - -**Fix:** -Add a main-process active-download count check in `startDownload`: -```typescript -if (active.size >= settings.maxConcurrent) { - return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot.' } -} -``` - ---- - -#### S4 — `filenameTemplate` and `outputDir` have no path-traversal checks - -**File:** [settings.ts:150-157](src/main/settings.ts) -**Severity:** Low -**Status:** Fixed — 2026-06-23 - -**Description:** -These fields are validated as `typeof === 'string'` only. A template like `%(title)s\..\..\..\win32.exe.%(ext)s` is joined into `-o` ([download.ts:195](src/main/download.ts)) and permits yt-dlp to write outside the intended folder. Also, `proxy` may carry plaintext credentials (user:pass@...) that get serialized by `exportBackup`. - -**Why it matters:** -On a single-user machine, self-inflicted. But on a shared PC (the app's stated target use case), directory traversal lets one user overwrite another's files. - -**Fix:** -Sanitize `outputDir` (must be absolute, within a reasonable parent) and `filenameTemplate` (reject `..` and absolute paths). For proxy, either mask credentials in backups or document that they're stored plaintext. - ---- - -#### S5 — Persisted JSON (history, templates, errorlog) read without per-field validation - -**Files:** -- [history.ts:17-18](src/main/history.ts) -- [templates.ts:16-17](src/main/templates.ts) -- [errorlog.ts:16-17](src/main/errorlog.ts) - -**Severity:** Low -**Status:** Fixed — 2026-06-23 - -**Description:** -All three do `JSON.parse(...) as T[]` with only an `Array.isArray` gate and no per-field validation. A hand-edited or corrupted file yields entries the UI blindly trusts (e.g., a `HistoryEntry` with `filePath: "C:\\bad"` that passes through to `openPath`). Inconsistent with the rigorous validation elsewhere. - -**Why it matters:** -Low immediate risk (malformed entries mostly degrade gracefully). But it's an untrusted-input boundary that doesn't match the codebase's defensive posture. - -**Fix:** -Add schema validators (`ts-json-validator`, `zod`, or lightweight custom checks) for each type: -```typescript -function isValidHistoryEntry(obj: unknown): obj is HistoryEntry { - return obj && typeof obj === 'object' && 'id' in obj && typeof obj.id === 'string' && ... -} -``` - ---- - -### Correctness - -#### C1 — Bundled `ffprobe.exe` was missing — duration-dependent post-processing failed for every user - -**Files:** [resources/bin/README.md](resources/bin/README.md), [binaries.ts](src/main/binaries.ts), [download.ts:235](src/main/download.ts) -**Severity:** High -**Status:** Fixed — 2026-06-23 - -**Description:** -`resources/bin/` shipped `ffmpeg.exe` but **not** `ffprobe.exe`, and the README only documented copying `ffmpeg.exe`. yt-dlp resolves *both* binaries from `--ffmpeg-location `; without `ffprobe.exe` it cannot read media durations, so any post-processor that needs one fails at runtime with `ERROR: Postprocessing: Unable to determine video duration: ffprobe not found`. That breaks `--sponsorblock-remove`, `--force-keyframes-at-cuts`, and `--split-chapters` for **every** user — end-user machines have no system ffprobe either. It slipped past review because thumbnail/crop/metadata post-processing only uses ffmpeg, and typecheck can't see a missing binary. - -**Found by:** -The real-download smoke test ([real-download.integration.test.ts](test/real-download.integration.test.ts)) — the SponsorBlock-remove case failed with exit 1 (`ffprobe not found`) until ffprobe was bundled. Everything else (crop, audio re-encode, container/codec, subs, chapters, restrict-filenames, archive, extra-args) passed. - -**Fix:** -Copied the matching `ffprobe.exe` (same `n8.1.2` LGPL build, verified by SHA-256 against the already-bundled `ffmpeg.exe`) into `resources/bin/`, and updated the README to list it as a required binary alongside `ffmpeg.exe`. The integration suite now also asserts `ffprobe.exe` is present in `beforeAll`. - -**Hardening (done — 2026-06-23):** -`startDownload` now asserts `ffmpeg.exe` and `ffprobe.exe` presence up front ([download.ts](src/main/download.ts)), alongside the existing `yt-dlp.exe` check — a future missing binary returns a clear AeroFetch error naming the file, instead of a cryptic mid-download yt-dlp postprocessing failure. (`getFfprobePath()` added to [binaries.ts](src/main/binaries.ts).) - ---- +### Reliability +- **[Critical] No download stall timeout (B1).** *Reasoning:* `spawn` has no timeout + no `--socket-timeout`; + a dead connection hangs forever and permanently consumes a concurrency slot. *Fix:* `--socket-timeout` + + an idle watchdog that kills+errors via the existing `killTree`. *Benefit:* downloads self-recover on flaky networks. +- **[Critical] Non-atomic writes + silent corruption→data-loss (R1/R2).** *Reasoning:* `writeFileSync` + isn't atomic; a crash mid-write corrupts the store and the next read returns `[]`, silently wiping history/ + sources. *Fix:* atomic write (temp+rename) + back up a corrupt file before resetting, in one `jsonStore`. + *Benefit:* user data survives crashes; no silent loss. +- **[High] Reverse-order batch downloads (M32)** & **history duplicates (M35).** *Reasoning:* `addMany`+`pump` + promote highest-index first; `redownload` mints a new id defeating dedup. *Fix:* reverse the batch enqueue; + dedup history by URL. *Benefit:* playlists download 1→N; clean history. +- **[High] Optimistic state never reconciles (M34).** *Reasoning:* renderer keeps a value main rejected. + *Fix:* apply the validated `Settings` the IPC returns. *Benefit:* the UI never lies about what's saved. ### Performance - -#### P1 — `getSettings()` writes to disk on every read - -**File:** [settings.ts:91-98](src/main/settings.ts) -**Severity:** Medium -**Status:** Fixed — 2026-06-23 - -**Description:** -Every call to `getSettings()` unconditionally runs `s.set('downloadOptions', sanitizeOptions(...))` and (on first run) `s.set('outputDir', ...)`. `getSettings()` is on hot paths: `buildCommand`, notification checks, system-theme bridge updates, several IPC handlers. Since `electron-store` serializes to disk on each `set`, this means synchronous file I/O per settings read. - -**Why it matters:** -Unnecessary disk churn. Settings reads are frequent (before every download, on IPC calls). Visible on slower machines or filesystems. - -**Fix:** -Sanitize once at store init or wrap `set` in a dirty-flag check: -```typescript -export function getSettings(): Settings { - const s = getStore() - const cur = s.store - const sanitized = sanitizeOptions(cur.downloadOptions) - if (sanitized !== cur.downloadOptions) s.set('downloadOptions', sanitized) - // Only write outputDir once, on first launch - if (!cur.outputDir) s.set('outputDir', app.getPath('downloads')) - return s.store -} -``` - ---- - -#### P2 — Every download spawns a redundant metadata probe - -**File:** [download.ts:249](src/main/download.ts) -**Severity:** Medium -**Status:** Fixed — 2026-06-23 - -**Description:** -`startDownload` always calls `probeMeta` (a second yt-dlp process with `--skip-download`) in parallel with the real download to fetch title/channel/duration. However, the renderer typically already probed and passes this metadata via `addFromUrl`'s optional `meta` parameter ([downloads.ts:60-65](src/renderer/src/store/downloads.ts)). That metadata is never forwarded to `startDownload`, so the main process re-fetches it. - -Result: doubled network traffic and process spawns; the async metadata completion can overwrite good titles the renderer already provided. - -**Why it matters:** -Wasted I/O on every download, especially for large playlists (where the renderer pre-probed each entry). On slow connections, observable slowdown. - -**Fix:** -Add optional `meta` fields to `StartDownloadOptions` and use them if present: -```typescript -export interface StartDownloadOptions { - // ... existing fields ... - meta?: DownloadMeta // optional pre-probed metadata -} - -// In download.ts: -let resolvedTitle = opts.meta?.title ?? (await probeMeta(...)) -if (opts.meta) { - send(wc, { type: 'meta', id: opts.id, meta: opts.meta }) -} else { - probeMeta(ytdlp, opts.url).then(...) -} -``` - ---- - -#### P3 — Lowering `maxConcurrent` mid-flight doesn't pause overflow - -**File:** [downloads.ts:249-265](src/renderer/src/store/downloads.ts) -**Severity:** Low (UX) -**Status:** Fixed — 2026-06-23 (documented in code; intentional behaviour) - -**Description:** -`pump()` only gates *future* promotions of queued items. Reducing `maxConcurrent` while N downloads are active leaves all N running (no immediate pause). Acceptable behavior, but not obvious. - -**Why it matters:** -Users may expect "set max to 1" to pause other downloads immediately. Instead, ongoing ones keep running until they finish. - -**Fix:** -Document the behavior, or add a `cancel-overflow` handler if stricter semantics are desired. For now, a comment suffices. - ---- +- **[High] O(n²) media-items rewrite (R3/PERF7).** *Reasoning:* every completion re-reads+rewrites the + whole (≤20k-item) file synchronously. *Fix:* in-memory cache + batched atomic writes (the same `jsonStore`). + *Benefit:* channel downloads stop hitching the main process. +- **[Medium] Per-download redundant work (PERF1/PERF2).** *Reasoning:* `templates.json` read + settings + decrypt on every spawn even when unused. *Fix:* gate `listTemplates()` on the consent flag; cache decrypted + settings. *Benefit:* lower latency/IO per download. +- **[Low] `summarizeQueue`/`pump` O(n) per tick (PERF3/PERF4).** *Fix:* memoize + count-based pump. *Benefit:* scales to large queues. ### Maintainability +- **[High] Triplicated Settings/`Api` + the preview mock (C1).** *Reasoning:* a 4th touch-point per IPC + method; drift-prone. *Fix:* `DEFAULT_SETTINGS` in `@shared`; one typed `mockApi`. *Benefit:* one place to change. +- **[High] Store circular dependency (C2).** *Fix:* a coordinator/event bus. *Benefit:* removes a latent init crash. +- **[Medium] Duplicated persistence/validation/async/formatters (M1/CC9/CC5/M9).** *Fix:* the `lib/` helpers + in SIMP1–SIMP5. *Benefit:* ~−500 LOC and fewer divergence bugs. +- **[Medium] God files (H1).** *Fix:* SettingsView via ``/`` (SIMP10). *Benefit:* 1104→~600 lines, reviewable. -#### M1 — `cleanError` function duplicated +### Consistency +- **[Medium] Two ways to do X** across persistence, status chips, segmented controls, buttons, errors + (M1/UI18/UI14/UI15/CC6). *Fix:* one shared primitive/standard each (see "Recommended single style"). *Benefit:* the app reads as one product. +- **[Low] Naming/copy drift** (CC1, L95/L150, separators L164). *Fix:* the conventions in CC1. *Benefit:* professional finish. -**Files:** -- [download.ts:81-88](src/main/download.ts) -- [probe.ts:139-146](src/main/probe.ts) +### Readability +- **[Medium] Magic strings/numbers + long signatures + deep nesting (CL1/CL2/CL4, L10).** *Fix:* `constants.ts`, + options-objects, extracted helpers. *Benefit:* faster comprehension, fewer transpose bugs (CL2). -**Severity:** Trivial -**Status:** Fixed — 2026-06-23 (extracted to [log.ts](src/main/log.ts)) +### Polish (perceived quality) +- **[High] First-run defaults (SR1/SR2/SR3).** *Reasoning:* light-on-dark first launch, nightly yt-dlp by + default, auto-download-new on. *Fix:* `theme:'system'`, `ytdlpChannel:'stable'`, `autoDownloadNew:false`. + *Benefit:* the first 10 seconds feel native and safe. +- **[High] Stuck "Resolving…" (SR6) & restarting progress bar (SR7).** *Fix:* clear placeholder on error; + weight the two merge phases. *Benefit:* nothing looks hung/glitchy. +- **[High] Placeholder icon + unsigned build (W14, SIGNING.md).** *Fix:* designed icon; purchase + wire a + cert (already env-ready). *Benefit:* no SmartScreen scare; brand credibility. -**Description:** -Identical 8-line error-log parsing function in two places. If the format changes, both need updating. +### User experience +- **[High] Per-download options are unreachable (UX1).** *Reasoning:* must change global settings to tweak + one download. *Fix:* wire the existing `DownloadOptionsForm` into the bar (plumbing exists). *Benefit:* reclaims a whole feature. +- **[High] Destructive actions have no confirmation/undo (UX4).** *Fix:* confirm Clear/Remove/Delete. *Benefit:* prevents data loss. +- **[Medium] Silent failures on Open/Show (UX6)** and **no global download status off the Downloads tab (UX9).** *Fix:* surface errors; sidebar badge. *Benefit:* the app feels responsive and honest. -**Fix:** -Extract to a shared utility, e.g., `src/main/log.ts` or add to `download.ts` and import in `probe.ts`. +### Accessibility & Windows-native (UX-critical for a Windows product) +- **[High] Fragmented/invisible focus + no list keyboard nav (UI28/29, W7).** *Fix:* one focus-ring + roving + list focus + Delete/Ctrl+A. *Benefit:* keyboard- and Narrator-usable. +- **[Medium] Title bar ignores in-app theme (W3); text fields have no context menu (W4); no `aria-live` (W17); + inputs lack accessible names (M28).** *Fix:* sync `themeSource`; add an editing context menu; live regions; + `aria-label`s. *Benefit:* feels like a native, accessible Windows app. + +### Developer experience +- **[High] No enforcement tooling (CC2) + `noImplicitAny:false` (M38).** *Reasoning:* style/type safety rely + on discipline; implicit `any` is allowed. *Fix:* Prettier + typescript-eslint + CI; re-enable `noImplicitAny`. + *Benefit:* the conventions stay true; regressions caught pre-merge. +- **[Medium] No logging + no prod source maps (CC8/M29/L170).** *Fix:* one leveled file logger at every catch; + hidden source maps. *Benefit:* field crashes become diagnosable instead of invisible. +- **[Low] Stale roadmap/docs (M25/M26, L80–L82); release checksum is manual (H8).** *Fix:* reconcile docs; + generate `.sha256` in `build:win`. *Benefit:* trustworthy docs; updates don't silently fail to install. + +## Suggested PR sequence to 1.0 + +1. **Correctness & data safety:** B1, M32, M35, M34, and the cached/atomic `jsonStore` (R1/R2/R3/PERF7). +2. **Security & trust:** H7 (encrypt cookies), H8 (auto-generate checksums), code-signing. +3. **First-run polish:** SR1/SR2/SR3 defaults, SR6/SR7 status, W14 icon, W3 title-bar theme. +4. **Wire-or-cut + a11y + tooling:** UX1 (per-download panel) or remove M5/M6; focus/keyboard (UI28/29, W7, + W4); Prettier+ESLint+`noImplicitAny` (CC2/M38). +5. **Incremental cleanup (post-1.0):** the SIMP refactors and the Low/UI/UX long tail, behind the new lint gate. --- -#### M2 — `parseExtraArgs` has no escape-sequence handling +## Critical -**File:** [buildArgs.ts:112-120](src/main/buildArgs.ts) -**Severity:** Trivial -**Status:** Fixed — 2026-06-23 (limitation documented in code) +- [ ] **C1 — Single source of truth for the IPC mock + Settings defaults.** `const PREVIEW` + redeclared in 8 files; `main.tsx` reimplements the entire `Api` (~180 lines); the full + `Settings` object is hand-maintained in 3 places (`main/settings.ts` `DEFAULTS`, + `renderer/store/settings.ts` `FALLBACK`, `main.tsx` `MOCK_SETTINGS`). Add `DEFAULT_SETTINGS` + to `shared/ipc.ts`, extract the preview mock into one `mockApi.ts` typed as `Api`, centralize + `isPreview`. +- [ ] **C2 — Break the `downloads ↔ sources` store circular dependency.** `downloads.ts` + imports `useSources`; `sources.ts` imports `useDownloads`. Works only via lazy `.getState()`. + Introduce a coordinator/event bus that owns cross-store reactions. -**Description:** -The shell-like split supports single and double quotes but no escape sequences (`\"` or `\'`). A value containing a literal quote can't be expressed, and an unterminated quote falls through to `\S+` and captures the quote itself. The tests cover the happy path; edge cases aren't reachable in practice (the UI doesn't expose raw quote entry). +## High -**Why it matters:** -Limitation of the current design, documented nowhere. +- [ ] **H1 — Decompose god files.** `SettingsView.tsx` (1104, ~11 cards, ~30 selector subs + + ~20 useState), `DownloadBar.tsx` (858, ~17 state hooks), `store/downloads.ts` (614). +- [ ] **H2 — Consolidate scattered utilities.** `youtubeId` ×2; 4 YouTube-URL-parsing variants; + byte/speed/eta/duration formatting across 4 modules. +- [ ] **H3 — Fix `probe.ts → download.ts` dependency direction** (`fmtBytes` import); resolves + with H2's shared formatter. +- [ ] **H4 — Carry raw numbers across the progress boundary.** `DownloadProgress` ships + formatted `speed`/`eta` strings; `queueStats.ts` re-parses them back to numbers. +- [ ] **H5 — History re-download silently changes quality.** `HistoryView.redownload` passes the + stored `quality` (for format-picker downloads this is a *label* like "720p · mp4 · 184 MB") + back as `quality` with no `formatId`; `buildArgs.videoFormat()` has no matching case and falls + back to `bv*+ba/b` (Best). The queued item shows "720p…" while actually fetching Best. +- [ ] **H6 — TerminalView log grows unbounded.** `setLines((ls) => [...ls, …])` with no cap; a + verbose run (`-F`, `--verbose`) streams thousands of lines into React state. Every other + log/list in the app is capped — cap this too (and/or virtualize). +- [ ] **H7 — `cookies.txt` written in plaintext with no restrictive perms.** [cookies.ts](src/main/cookies.ts) + `writeFileSync(getCookiesFilePath(), …)` stores live auth/session cookies unencrypted under + `userData`. In the **portable build** that's `AeroFetch-data/` next to the exe (USB stick / + shared Downloads folder), so anyone with folder access can read a logged-in session. Settings + secrets are DPAPI-encrypted (settings.ts) but cookies are not. Encrypt at rest or document the + exposure for the portable/shared-PC scenario the app explicitly targets. +- [ ] **H8 — Release checksum is a manual step the updater hard-requires.** [updater.ts](src/main/updater.ts) + sets `REQUIRE_CHECKSUM = true` and refuses any update lacking a `.sha256`, but + `build:win` (`electron-vite build && electron-builder --win`) never generates one. Evidence in + `dist/`: only `0.4.1` has `.sha256` files (hand-made); the current `0.5.0` build does not. If a + release is published without manually adding the checksum, **every client's in-app update + fails** ("This release has no checksum … refusing to install"). Generate the `.sha256` in the + build/release script. -**Fix:** -Add a note near `parseExtraArgs`: "No escape-sequence support; quotes cannot nest. For most yt-dlp use cases (proxy URLs, filename patterns), this is sufficient." If a future template needs quotes within quotes, redesign to a proper shlex or JSON-based format. +## Medium + +- [ ] **M1 — Unify JSON persistence.** `sources.ts` has generic `readJsonArray`/`writeJson`; + `history.ts`/`errorlog.ts`/`templates.ts` reimplement it inline. +- [ ] **M2 — Remove dead code: `getDefaultFolder` / `download:default-folder`** (channel + + preload + handler + mock, zero callers). +- [ ] **M3 — Remove duplicated completion side-effect in `store/downloads.ts`** (history write + + `markDownloaded` in both `applyEvent('done')` and the preview ticker). Subsumed by C2. +- [ ] **M4 — Decide queue persistence explicitly.** Scheduler/queue is renderer-memory only; + `saved`/scheduled items don't survive a quit. Persist or document as a non-goal in code. +- [ ] **M5 — Command-preview feature is fully built but unwired.** `CommandPreviewResult` type + + `command:preview` channel + `previewCommand` (download.ts) + `formatCommandLine`/ + `quoteForDisplay` (buildArgs) + preload method all exist, but no renderer component calls + `window.api.previewCommand` (only the `main.tsx` mock references it). Wire it into the + DownloadBar Options panel, or remove the dead chain. +- [ ] **M6 — Private/incognito mode is plumbed but unreachable.** `incognito` flows through + `AddOptions` → `buildItem` → history-skip → the QueueItem "Private" badge, but nothing in the + UI ever sets `incognito: true`. Add the toggle or remove the dead plumbing. +- [ ] **M7 — `newId()` duplicated 3×** (`store/downloads.ts`, `TerminalView.tsx`, + `TemplateManager.tsx`) with divergent fallback prefixes (`item-`/`t-`/`tpl-`). Extract one helper. +- [ ] **M8 — Two download-status→label maps.** `STATUS_BADGE` (QueueItem) and `STATUS_LABEL` + (LibraryView) independently map the same statuses (completed = "Completed" vs "Downloaded"). + One shared map. +- [ ] **M9 — Three time formatters.** `relTime` (LibraryView), `formatWhen` (HistoryView), + `fmtSchedule` (QueueItem) — consolidate into a date util. +- [ ] **M10 — `.url` shortcut parsing duplicated** — `parseUrlFile` (DownloadBar) vs + `readUrlShortcut` (main/deeplink). Different `URL=` extractors for the same file format. +- [ ] **M11 — Inconsistent clipboard access.** Reads use `navigator.clipboard.readText` (paste + button) *and* `window.api.readClipboard` (suggestion watcher); writes use + `navigator.clipboard.writeText` (copy report). Pick one strategy. +- [ ] **M12 — Shared `errorText` style.** `tokens.colorPaletteRedForeground1` is applied inline + 12× across 5 files instead of one class. +- [ ] **M13 — Inconsistent secret-field masking.** `updateToken` uses `type="password"`; `proxy` + (may carry `user:pass@`) and `youtubePoToken` (a token) are plain-text Inputs. +- [ ] **M14 — Settings search mutates React-owned DOM.** The search toggles each card's + `el.style.display` directly; fragile (breaks if a card ever gets a conditional `style`) and + matches on `textContent` incl. hidden text. Prefer state-driven filtering. +- [ ] **M15 — Nested interactive controls in `role="button"` (a11y).** LibraryView's group header + is a `role="button"` div containing ` + /> ))} @@ -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)} /> @@ -792,20 +764,15 @@ export function DownloadBar(): React.JSX.Element {
Format -
- {(['video', 'audio'] as MediaKind[]).map((k) => ( - - ))} -
+ + value={kind} + options={[ + { value: 'video', label: 'Video' }, + { value: 'audio', label: 'Audio' } + ]} + onChange={onKindChange} + ariaLabel="Format" + />
diff --git a/src/renderer/src/components/DownloadOptionsForm.tsx b/src/renderer/src/components/DownloadOptionsForm.tsx index d287f8b..d1cd345 100644 --- a/src/renderer/src/components/DownloadOptionsForm.tsx +++ b/src/renderer/src/components/DownloadOptionsForm.tsx @@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record = { const SPONSORBLOCK_LABELS: Record = { 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)} /> - + setOpt('metadataTitle', d.value)} + /> + setOpt('metadataArtist', d.value)} + /> + setOpt('metadataAlbum', d.value)} + /> +
+ 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 ( -
+
+ + {summary.active && ( @@ -96,7 +110,7 @@ export function DownloadsView(): React.JSX.Element { )}
- Queue ({items.length}) + Queue ({queueCount})
{summary.failed > 0 && ( +
+ ) + } +} diff --git a/src/renderer/src/components/HistoryView.tsx b/src/renderer/src/components/HistoryView.tsx index 2b0866f..0828a1e 100644 --- a/src/renderer/src/components/HistoryView.tsx +++ b/src/renderer/src/components/HistoryView.tsx @@ -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>(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 ( -
-
- +
+ +
+
+ +
+ No downloads yet. + Finished downloads will show up here.
- No downloads yet. - Finished downloads will show up here.
) } return ( -
+
+
{selectMode ? ( <> @@ -228,18 +230,44 @@ export function HistoryView(): React.JSX.Element { {allFilteredSelected ? 'Select none' : 'Select all'}
- - + {confirmDelete ? ( + <> + + Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}? + + + + + ) : ( + <> + + + + )} ) : ( <> @@ -248,6 +276,7 @@ export function HistoryView(): React.JSX.Element { setQuery(d.value)} @@ -270,9 +299,36 @@ export function HistoryView(): React.JSX.Element { > Select - + {confirmClear ? ( + <> + + Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}? + + + + + ) : ( + + )} )}
@@ -280,10 +336,33 @@ export function HistoryView(): React.JSX.Element { {filtered.length === 0 ? ( No downloads match your search. ) : ( -
+
{ + // 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 ( -
+
{ + // 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 && ( = { - 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(null) + const [confirmRemoveId, setConfirmRemoveId] = useState(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 { @@ -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(() => { 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 (
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 ? : } @@ -486,6 +455,7 @@ export function LibraryView(): React.JSX.Element {
toggle(it.id, !!d.checked)} aria-label={`Select ${it.title}`} /> @@ -501,23 +471,22 @@ export function LibraryView(): React.JSX.Element { {it.durationLabel && {it.durationLabel}}
- {STATUS_LABEL[status]} +
) } return ( -
-
- Library - - Index a channel or playlist once, then download it into organized folders. - -
+
+
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)} >
@@ -634,7 +601,11 @@ export function LibraryView(): React.JSX.Element {
{selected.size > 0 ? ( <> - - + {confirmRemoveId === src.id ? ( + <> + Remove {src.title}? + + + + ) : ( + + )}
{batchNote && {batchNote}} @@ -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 (
>(new Map()) + + useEffect(() => { + function check(items: ReturnType['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 ( +
+ {message} +
+ ) +} diff --git a/src/renderer/src/components/MediaThumb.tsx b/src/renderer/src/components/MediaThumb.tsx index 3b1b044..bc1e0fd 100644 --- a/src/renderer/src/components/MediaThumb.tsx +++ b/src/renderer/src/components/MediaThumb.tsx @@ -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(null) const showImg = !!src && failedSrc !== src diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx index 82697da..2098b87 100644 --- a/src/renderer/src/components/Onboarding.tsx +++ b/src/renderer/src/components/Onboarding.tsx @@ -112,8 +112,8 @@ export function Onboarding(): React.JSX.Element { Videos save to your Documents\Video folder and audio to{' '} - Documents\Audio. You can point each to a different folder any time - in Settings. + Documents\Audio. You can point each to a different folder any time in + Settings. diff --git a/src/renderer/src/components/QueueItem.tsx b/src/renderer/src/components/QueueItem.tsx index cdf2297..846e6cc 100644 --- a/src/renderer/src/components/QueueItem.tsx +++ b/src/renderer/src/components/QueueItem.tsx @@ -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 = { - 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 ( -
+
)} {item.title} - - {badge.label} - + {item.incognito && ( @@ -181,11 +175,24 @@ export const QueueItem = memo(function QueueItem({ {item.status === 'downloading' && (
- + {/* Indeterminate when finishing (the second stream / merge reads as + "working" rather than a 0→100% restart, SR7) or when yt-dlp can't + report a total size, so the bar never sits frozen at 0% (L137). */} + - {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` : ''} + + )}
)} @@ -206,7 +213,9 @@ export const QueueItem = memo(function QueueItem({ {item.status === 'saved' && ( - {item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'} + {item.scheduledFor + ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` + : 'Saved for later'} )} diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index d7bae13..a0c5923 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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(null) const [ffmpeg, setFfmpeg] = useState(null) + const [mintingPot, setMintingPot] = useState(false) + const [potHint, setPotHint] = useState(null) const [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState(null) @@ -273,22 +274,23 @@ export function SettingsView(): React.JSX.Element { const [exportResult, setExportResult] = useState(null) const [importing, setImporting] = useState(false) const [importResult, setImportResult] = useState(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 { @@ -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 ( -
+
+
+ +
setSearch(d.value)} placeholder="Search settings…" @@ -682,7 +694,7 @@ export function SettingsView(): React.JSX.Element { - update({ youtubePoToken: d.value })} - /> +
+ { + update({ youtubePoToken: d.value }) + setPotHint(null) + }} + /> + +
@@ -720,8 +766,8 @@ export function SettingsView(): React.JSX.Element { Cookies
- 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. @@ -736,7 +782,7 @@ export function SettingsView(): React.JSX.Element { {cookieSource === 'browser' && ( update({ updateToken: d.value })} contentBefore={} /> @@ -995,7 +1061,10 @@ export function SettingsView(): React.JSX.Element { {appDownloading ? 'Downloading…' : 'Update now'} {appUpd.htmlUrl && ( - )} @@ -1013,11 +1082,7 @@ export function SettingsView(): React.JSX.Element { You're up to date — v{appUpd.currentVersion} is the latest. )} - {appUpdError && ( - - {appUpdError} - - )} + {appUpdError && {appUpdError}} @@ -1026,13 +1091,13 @@ export function SettingsView(): React.JSX.Element { About
- 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. yt-dlp {version.version} )} {version && !version.ok && ( - - {version.error} - + {version.error} )} {ffmpeg && ( <> @@ -1094,9 +1157,7 @@ export function SettingsView(): React.JSX.Element { )} {updateResult && !updateResult.ok && ( - - {updateResult.error} - + {updateResult.error} )}
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 4f0b61f..e95bf5d 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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({
@@ -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 ? ( - + aria-label={`Change theme (currently ${themeLabel})`} + /> ) : ( -
- {THEMES.map((t) => { - const on = theme === t.value - return ( - - ) - })} -
+ + fitted + value={theme} + options={THEMES} + onChange={onSetTheme} + ariaLabel="Theme" + /> )} ) diff --git a/src/renderer/src/components/TemplateManager.tsx b/src/renderer/src/components/TemplateManager.tsx index 412961c..e0ad84a 100644 --- a/src/renderer/src/components/TemplateManager.tsx +++ b/src/renderer/src/components/TemplateManager.tsx @@ -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 {
) : ( - )} diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 4fd1ee9..ccec5ed 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -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 ( -
-
- Terminal - - Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} - -F https://youtu.be/…. ffmpeg is wired up automatically. - -
+
+ + Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} + -F https://youtu.be/…. ffmpeg is wired up automatically. + + } + /> {!customCommandEnabled && ( @@ -149,6 +154,7 @@ export function TerminalView(): React.JSX.Element {
yt-dlp