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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:02:54 -04:00
parent a6a8c5f578
commit 1376c2dee8
82 changed files with 4571 additions and 1276 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
+188 -121
View File
@@ -7,6 +7,25 @@ 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).
**Session 2026-06-30 (correctness/safety/strictness pass):** landed B3 B4 B5 B7 (checksum-
filename match, newline-safe meta probe, canceled-event guards, cookie-login never-resolve),
L88 L146 (queue/trim edge cases), **L168 + L169** (`noUncheckedIndexedAccess` +
`noFallthroughCasesInSwitch` now on — 0 errors after 8 real edge-case fixes), R5 (settings
write failure handled), W1 W5 W6 (min window size, seeded folder picker, parented sign-in
window), CL1 (shared stdout markers), L147 (dead macOS branches removed), M8 (StatusChip
already unified the status→label map), M36 (Library selection only counts actionable rows),
L11 (Queue header counts the live queue), L50 (no "saved" for 0 cookies), L156 + L57
(schedule picker `min`), L159 L15 L3 (dead-code/comment cleanup), package/builder metadata
(L4 L41 L42 L43 L45 L58), user-facing copy (L66 L154; partial M37/SR9 jargon), doc
reconciliation (M25 M26 L80 L81), and new unit tests (L35 isValidMediaItem, L36
compareVersions). All verified: typecheck + 234 tests + eslint + prettier green.
**Deliberately deferred** (need live-app/visual verification or are larger refactors the
audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store
refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations,
PERF8 code-splitting, and the visual UI/UX polish long-tail. These are best done as their
own PRs with the app running so the UI can actually be checked.
---
# 1.0 Release-Readiness Audit (lead-engineer synthesis)
@@ -46,7 +65,9 @@ None require rearchitecting.
## Release gate
**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.
B1 · M32 · M35 · M34 · R1 · R2 · R3 · H7 · H8 · SR1 · SR6 · SR7 · W14 — **all resolved as of this
session.** Code signing was dropped from the gate: it's a deliberate non-goal (no certificate will be
purchased; the SmartScreen prompt on the unsigned build is accepted).
**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
@@ -103,8 +124,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*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.
- **[High] Placeholder icon (W14).** *Fixed:* a designed mark (teal gradient square + top sheen, bold
rounded download glyph over a landing shelf) replaces the flat placeholder; multi-size `.ico` regenerated.
*Benefit:* brand credibility. *(Unsigned build is a non-goal — no cert; the SmartScreen prompt is accepted.)*
### User experience
- **[High] Per-download options are unreachable (UX1).** *Reasoning:* must change global settings to tweak
@@ -131,7 +153,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
## 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.
2. **Security & trust:** H7 (encrypt cookies), H8 (auto-generate checksums). *(Code signing is a non-goal — no cert.)*
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).
@@ -168,13 +190,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **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)
- [x] **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)
- [x] **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 `<asset>.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
@@ -184,9 +206,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
## Medium
- [ ] **M1 — Unify JSON persistence.** `sources.ts` has generic `readJsonArray`/`writeJson`;
- [x] **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 +
- [x] **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.
@@ -200,20 +222,29 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **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`,
- [x] **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`
*Fixed: one [`newId(prefix)`](src/renderer/src/id.ts) helper; the three copies import it and pass their
prefix. The fallback gained a monotonic counter so same-millisecond ids can't collide (subsumes L33/L70).
Unit-tested in `test/id.test.ts`.*
- [x] **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.
- [x] **M9 — Three time formatters.** `relTime` (LibraryView), `formatWhen` (HistoryView),
`fmtSchedule` (QueueItem) — consolidate into a date util. *Fixed: all three moved to
[`datetime.ts`](src/renderer/src/datetime.ts) and imported by their views; unit-tested in `test/datetime.test.ts`.*
- [ ] **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.
- [x] **M12 — Shared `errorText` style.** `tokens.colorPaletteRedForeground1` is applied inline
12× across 5 files instead of one class. *Partially fixed: added
[`useErrorTextStyles`](src/renderer/src/components/ui/errorText.ts) (`error` / `errorPre` for pre-wrap
multi-line output) and adopted it in `SettingsView.tsx` — the biggest offender — replacing all 6 inline
`style={{ color: … }}` error spans + its local `errorRowText` class. The remaining red references live in
per-component `makeStyles` classes (DownloadBar/LibraryView/QueueItem/TerminalView), not inline; migrate
those to the shared hook incrementally.*
- [ ] **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
@@ -222,8 +253,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **M15 — Nested interactive controls in `role="button"` (a11y).** LibraryView's group header
is a `role="button"` div containing `<Button>`s (All / Download) — invalid ARIA / keyboard
semantics. Make the header a real element with sibling buttons.
- [ ] **M16 — No renderer error boundary.** An exception in any view unmounts the whole shell to
a blank window. Add a top-level boundary with a recover/reload affordance.
- [x] **M16 — No renderer error boundary.** An exception in any view unmounts the whole shell to
a blank window. Add a top-level boundary with a recover/reload affordance. *Fixed: added
[`ErrorBoundary`](src/renderer/src/components/ErrorBoundary.tsx) wrapping `<App/>` in `main.tsx`. The
fallback is dependency-free (no Fluent/theme provider — the crash may have come from that tree), logs via
`componentDidCatch`, shows the error message, and offers a Reload button (`window.location.reload()`;
persisted data lives in main, so nothing is lost).*
- [ ] **M17 — `statusByUrl` collapses duplicate URLs.** LibraryView maps URL→status into one
`Map`; with "Download anyway" duplicates, a row can reflect the wrong item's state.
- [ ] **M18 — Audio "quality" presets hard-code MP3.** `QUALITY_OPTIONS.audio` labels ("Best
@@ -236,23 +271,27 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`aria-label` (double announcement).
- [ ] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for
lossless `audioFormat` (flac/wav), where it's meaningless; pair with M18's preset/format mismatch.
- [ ] **M22 — Backup export writes secrets in cleartext, silently.** [backup.ts](src/main/backup.ts)
- [x] **M22 — Backup export writes secrets in cleartext, silently.** [backup.ts](src/main/backup.ts)
`exportBackup` serializes the *decrypted* settings (proxy creds, PO token, update token) to a
plain JSON file; the UI caption only says "Does not include download history" — no warning that
the file contains credentials. Mask/omit secrets in the export, or warn before writing.
the file contains credentials. *Fixed: `proxy`/`youtubePoToken`/`updateToken` are stripped (set to
`''`) before writing; SettingsView caption updated to say credentials are not included.*
- [ ] **M23 — `MAX_ITEMS` truncation can silently drop a source's items.** [sources.ts](src/main/sources.ts)
`replaceMediaItems` does `[...new, ...others].slice(0, 20000)`; once the global total exceeds the
cap, the *tail* (another source's items) is dropped on write and only reappears when that source
is re-indexed. Cap per-source, or surface it.
- [ ] **M24 — `setSettings` accepts unvalidated free strings.** [settings.ts](src/main/settings.ts)
- [x] **M24 — `setSettings` accepts unvalidated free strings.** [settings.ts](src/main/settings.ts)
stores `rateLimit`/`proxy`/`defaultVideoQuality`/`defaultAudioQuality`/`youtubePlayerClient` as
any string. A bad `rateLimit` ("abc") only fails at download time; a `defaultQuality` not in
`QUALITY_OPTIONS` leaves the Settings dropdown's controlled value blank. Add light format/allowlist checks.
- [ ] **M25 — ROADMAP marks unwired features as ✅ shipped.** [ROADMAP.md](ROADMAP.md) Phase C
`QUALITY_OPTIONS` leaves the Settings dropdown's controlled value blank. *Fixed: `VIDEO_QUALITY_OPTIONS`
/ `AUDIO_QUALITY_OPTIONS` added to `shared/ipc.ts` and imported in both `settings.ts` (allowlist
validation) and `store/downloads.ts` (single source of truth). `rateLimit` validated against yt-dlp
rate format regex; `youtubePlayerClient` trimmed.*
- [x] **M25 — ROADMAP marks unwired features as ✅ shipped.** [ROADMAP.md](ROADMAP.md) Phase C
("Command preview … Surfaced as a **Preview command** button in the download bar") and Phase D
("Private / incognito mode — a download bar toggle") both carry `[x]`, but neither is wired in
the UI (corroborates M5/M6). The roadmap overstates completion — reconcile the docs or finish the wiring.
- [ ] **M26 — ROADMAP accent palette is wholesale stale.** [ROADMAP.md](ROADMAP.md) Phase E
- [x] **M26 — ROADMAP accent palette is wholesale stale.** [ROADMAP.md](ROADMAP.md) Phase E
describes "four accent presets — Toffee (original), Slate, Evergreen, Lavender" with a default of
Toffee; the shipped [theme.ts](src/renderer/src/theme.ts) is rose / coral / amber / teal
("Sunset-to-sea") with a default of **teal**. The whole section documents a palette that no
@@ -261,20 +300,32 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
`enqueueItems` forces `settings.defaultKind` (+ its quality) for *every* item, so a channel can't
be downloaded as audio without flipping the global default — inconsistent with the DownloadBar
playlist panel, which offers a per-item video/audio toggle.
- [ ] **M28 — Primary text inputs have no accessible name.** The URL field (DownloadBar), add-source
- [x] **M28 — Primary text inputs have no accessible name.** The URL field (DownloadBar), add-source
(Library), search (History/Settings), and the Terminal args `Textarea` rely on `placeholder` only —
which is not an accessible name. Add `aria-label`/`<label>` to each. *(a11y; distinct from M20's Select/ProgressBar.)*
- [ ] **M29 — Failures are swallowed everywhere.** 29 `.catch(() => {})` sites across 15 files: nearly
- [x] **M29 — Failures are swallowed everywhere.** 29 `.catch(() => {})` sites across 15 files: nearly
every IPC write/read discards its error with no log, no user feedback, and no telemetry. A failed
settings write, history add, or `openPath` simply vanishes. Add a central error sink (toast + log).
- [ ] **M30 — A broken contextBridge degrades silently to mock mode.** [preload/index.ts](src/preload/index.ts)
settings write, history add, or `openPath` simply vanishes. *Fixed: a shared
[`logError(op)`](src/renderer/src/reportError.ts) helper replaces every swallowed renderer catch with
one consistent `console.error('[AeroFetch] <op> failed:', e)` (settings, history, templates, errorlog,
sources, App, DownloadBar drag-drop, SettingsView version reads, LibraryView scheduled-sync); main-process
sites (ytdlp auto-update, cookies loadURL) log inline. Four genuinely-silent catches left intentionally:
`pauseDownload` / `cancelDownload` (process kill, state already applied), `unlink` (cleanup), and
`clipboard.writeText` (browser permission — no user action available). **Scope note:** this delivers
the "log" half — failures are no longer silent. The user-facing toast + a persistent log sink (renderer
`console.error` isn't captured in a packaged build) are deferred to **CC8** (electron-log), which the
audit already cross-references here.*
- [x] **M30 — A broken contextBridge degrades silently to mock mode.** [preload/index.ts](src/preload/index.ts)
only `console.error`s if `exposeInMainWorld` throws; the renderer then sees no `window.electron`,
flips `PREVIEW` true, and runs the **browser mock** (no real IPC) with no visible error. A real
bridge failure looks like "preview." Surface a hard error instead.
- [ ] **M31 — Default Electron menu (incl. Toggle DevTools/Reload) ships in production.** No
bridge failure looks like "preview." *Fixed: preload sends `IpcChannels.preloadBridgeFailure` on failure;
main registers `ipcMain.once(IpcChannels.preloadBridgeFailure, …)` in `registerIpcHandlers()` (run before
the window is created) and shows an error dialog + quits, so the failure is impossible to miss.*
- [x] **M31 — Default Electron menu (incl. Toggle DevTools/Reload) ships in production.** No
`Menu.setApplicationMenu(null)` is called; with `autoHideMenuBar` the default role menu is still
Alt-accessible, exposing DevTools/reload to end users. Set an explicit (or null) app menu.
- [ ] **M32 — Playlist/channel batches download in reverse order.** `addMany` prepends the whole
Alt-accessible, exposing DevTools/reload to end users. *Fixed: `Menu.setApplicationMenu(null)` called
in `app.whenReady()` when `!is.dev`; dev builds keep the menu for DevTools access.*
- [x] **M32 — Playlist/channel batches download in reverse order.** `addMany` prepends the whole
batch in entry order (`[entry1…entryN, …old]`), but `pump()` promotes the *highest-index* queued
item first (it reverses, assuming one-at-a-time prepends). So a selected playlist/channel downloads
**N → 1**. Files are still named `001…NNN` correctly (by `playlistIndex`), so a partial run leaves
@@ -284,15 +335,15 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
"never holds the whole collection at once," but no such cap exists — `enqueueItems` `addMany`s *every*
selected item, so "Download all pending" on a 5,000-video channel puts 5,000 items in the store/queue
at once. Implement the cap or fix the comment.
- [ ] **M34 — Optimistic settings updates never reconcile with main's validation.** The settings store
- [x] **M34 — Optimistic settings updates never reconcile with main's validation.** The settings store
does `set(partial)` then `setSettings(partial).catch(() => {})`, discarding the validated `Settings`
main returns. A value main rejects (e.g. an unsafe `filenameTemplate`, a clamped `maxConcurrent`, a
malformed `rateLimit`) still shows as accepted in the UI until restart — the user believes a setting
saved that didn't. Apply the returned authoritative state.
- [ ] **M35 — History re-download creates duplicate rows.** `redownload` re-queues via `addFromUrl`,
- [x] **M35 — History re-download creates duplicate rows.** `redownload` re-queues via `addFromUrl`,
which mints a **new id**; on completion `addHistory` dedups by id (no match) and prepends a second row
for the same video. Re-downloading from History accumulates duplicates. Dedup by URL, or reuse the entry id.
- [ ] **M36 — Library checkbox count ≠ "Download N selected".** Every item row has a checkbox
- [x] **M36 — Library checkbox count ≠ "Download N selected".** Every item row has a checkbox
([LibraryView.tsx](src/renderer/src/components/LibraryView.tsx)), but `selectedActionable` filters to
pending/error/canceled only, so selecting 5 rows (incl. 2 already-downloaded) shows "Download **3**
selected." The checkbox count and the button count silently disagree. **Fix:** only show checkboxes on
@@ -302,7 +353,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
token", "sent via `--extractor-args`", "breaking downloads with **403** errors", "open a locked cookie
database", and roadmap status ("automatic minting is **planned**"). These read as code comments, not
product copy. **Fix:** rewrite for end users (hide repo/flag/HTTP-status jargon and roadmap notes).
- [ ] **M38 — `noImplicitAny: false` partially defeats `strict`.** The inherited
- [x] **M38 — `noImplicitAny: false` partially defeats `strict`.** The inherited
`@electron-toolkit/tsconfig` sets `strict: true` **but explicitly `"noImplicitAny": false"`**, and
neither project tsconfig re-enables it — so a parameter/variable with no inferrable type silently
becomes `any` project-wide (the one real hole in an otherwise-strict setup; `noUnusedLocals`/
@@ -314,9 +365,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L1 — Module-load `setInterval` in `store/downloads.ts`** runs on import (incl. tests/preview).
- [ ] **L2 — `index.ts` flat IPC registration (~150 lines)** — let each main module export its own
`register(ipcMain)`.
- [ ] **L3 — Stale comments.** `getYtdlpVersion` JSDoc still calls it the "Step-1 spike";
- [x] **L3 — Stale comments.** `getYtdlpVersion` JSDoc still calls it the "Step-1 spike";
`vitest.config.ts` claims tests "only exercise buildArgs.ts" (9 test files now exist).
- [ ] **L4 — `electron-builder.yml` nits.** `copyright: yt-dlp frontend` is not a copyright
- [x] **L4 — `electron-builder.yml` nits.** `copyright: yt-dlp frontend` is not a copyright
string (no holder/year); the `files` exclude `tsconfig.tsbuildinfo` never matches the real
`tsconfig.web.tsbuildinfo`.
- [ ] **L5 — Inline `style={{}}` vs makeStyles** (25× across 8 files; SettingsView 14×). Notably
@@ -333,16 +384,16 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L10 — Scattered magic numbers.** Timeouts/caps spread across modules (probe 60s, indexer
180s, probeMeta 30s, update-idle 60s; MAX_ENTRIES 500/200, MAX_TEMPLATES 100, MAX_ITEMS 20000).
Consider a central constants module.
- [ ] **L11 — "Queue (N)" overcounts.** DownloadsView's header count is `items.length` (includes
- [x] **L11 — "Queue (N)" overcounts.** DownloadsView's header count is `items.length` (includes
completed/error/canceled), not the active queue.
- [ ] **L12 — Command palette polish.** No scroll-into-view for keyboard selection in the 50vh
list; `role="dialog"` without `aria-modal`/focus-trap; Esc handled only on the input.
- [ ] **L13 — Destructive actions lack confirmation.** "Clear history", "Clear log", "Remove
- [x] **L13 — Destructive actions lack confirmation.** "Clear history", "Clear log", "Remove
source" are one-click — inconsistent with the careful confirm on backup import.
- [ ] **L14 — `base.css` is bare** — no global `box-sizing`, `:focus-visible`, or font-smoothing
baseline, so custom native elements (Select, Hint, segmented controls) get inconsistent focus
rings; `box-sizing` is then set ad hoc on the SettingsView swatch.
- [ ] **L15 — `MediaThumb` redundant ternary** `kind === 'audio' ? 'audio' : 'video'` (kind is
- [x] **L15 — `MediaThumb` redundant ternary** `kind === 'audio' ? 'audio' : 'video'` (kind is
already that union).
- [ ] **L16 — `relTime`/`formatWhen` verbosity.** `relTime` returns unbounded "N d ago"; History's
`formatWhen` always appends the year, even for the current year.
@@ -380,29 +431,30 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
when collapsed — minor dual-UX for the same setting.
- [ ] **L32 — `paletteActions` rebuilt every render** in `App.tsx` (not memoized); harmless today,
but it's passed straight into a child.
- [ ] **L33 — `idCounter` fallback resets per launch.** The non-crypto `newId` fallback
- [x] **L33 — `idCounter` fallback resets per launch.** The non-crypto `newId` fallback
(`item-${++idCounter}`) restarts at 1 each run; the `Date.now()`-based fallbacks elsewhere can
collide within a millisecond. Unify on one robust id helper (see M7).
collide within a millisecond. Unify on one robust id helper (see M7). *Fixed with M7: the single
`newId` fallback is `${prefix}-${Date.now()}-${++counter}`, so same-ms ids stay distinct.*
*Round 3 (2026-06-29) — tests, build metadata, error copy, edge cases:*
- [ ] **L34 — Integration test off by default.** `real-download.integration.test.ts` is
`describe.skipIf(!RUN)`; `npm test` never exercises a real download, so there's no automated
regression for the actual yt-dlp argv path (only the pure builder).
- [ ] **L35 — `isValidMediaItem` has no test** — the one persisted-JSON validator of six with no spec.
- [ ] **L36 — `compareVersions` differing-length components untested** (e.g. `1.2` vs `1.2.0`).
- [x] **L35 — `isValidMediaItem` has no test** — the one persisted-JSON validator of six with no spec.
- [x] **L36 — `compareVersions` differing-length components untested** (e.g. `1.2` vs `1.2.0`).
- [ ] **L37 — `download.ts` formatters untested** (`parseProgress`/`fmtBytes`/`fmtEta`, incl. the `NA` paths).
- [ ] **L38 — `buildArgs` webm-thumbnail exclusion branch untested** (`embedThumbnail && container!=='webm'`).
- [ ] **L39 — `CROP_SQUARE_PPA` exact-string assertion** is a change-detector test (breaks on harmless reformat).
- [ ] **L40 — `clipboardLink.test.ts` imports the zustand settings store** to test two pure helpers
(`looksLikeUrl`/`looksLikeSingleVideo`) — couples a pure-fn test to store init (see H2).
- [ ] **L41 — `package.json` `homepage` points at yt-dlp's GitHub**, not AeroFetch — wrong product
- [x] **L41 — `package.json` `homepage` points at yt-dlp's GitHub**, not AeroFetch — wrong product
URL (surfaced by the NSIS installer).
- [ ] **L42 — `package.json` has no `license` field** (ships LGPL ffmpeg + GPL aria2c).
- [ ] **L43 — `package.json` has no `repository` field** (real repo is the Gitea instance).
- [x] **L42 — `package.json` has no `license` field** (ships LGPL ffmpeg + GPL aria2c).
- [x] **L43 — `package.json` has no `repository` field** (real repo is the Gitea instance).
- [ ] **L44 — Vestigial lint excludes.** `electron-builder.yml` excludes `.eslintrc`/`.prettierrc`
but there's no ESLint/Prettier config, script, or dependency — no enforced style tooling.
- [ ] **L45 — Three self-descriptions** drift: pkg `description` "A yt-dlp frontend for Windows" vs
- [x] **L45 — Three self-descriptions** drift: pkg `description` "A yt-dlp frontend for Windows" vs
builder `copyright` "yt-dlp frontend" vs Sidebar caption "yt-dlp frontend".
- [ ] **L46 — yt-dlp-missing error copy differs across 4 modules** (download/ytdlp/probe/indexer):
"Reinstall AeroFetch, or drop…" vs "Download it into resources/bin/…" vs "Drop it into resources/bin/."
@@ -412,7 +464,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
shows elsewhere (errorlog seed / terminal) — inconsistent error normalization.
- [ ] **L49 — Newlines in user-facing error strings.** download.ts missing-binary messages embed
`\n`, which renders awkwardly in inline/Caption error spans.
- [ ] **L50 — "Cookies saved" shown for 0 cookies.** Closing the sign-in window without logging in
- [x] **L50 — "Cookies saved" shown for 0 cookies.** Closing the sign-in window without logging in
still writes the file and reports `ok` with `cookieCount: 0`.
- [ ] **L51 — Scheduled daily sync time hardcoded to 09:00** (schedule.ts) — on/off toggle only, no time picker.
- [ ] **L52 — Installer handoff is a fixed `setTimeout(app.quit, 1500)`** (updater.ts) — a race on slow machines.
@@ -423,9 +475,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
contains the size, then `sizeLabel` is appended again.
- [ ] **L56 — SponsorBlock ON with zero categories silently no-ops** (`buildArgs` guards on
`length > 0`) with no UI warning.
- [ ] **L57 — Scheduling a past datetime silently downloads now** (`buildItem` future-only guard),
- [x] **L57 — Scheduling a past datetime silently downloads now** (`buildItem` future-only guard),
no feedback that the schedule was ignored.
- [ ] **L58 — `chooseFolder` passes the macOS-only `createDirectory` property** — dead option on Windows.
- [x] **L58 — `chooseFolder` passes the macOS-only `createDirectory` property** — dead option on Windows.
- [ ] **L59 — `THEME_BACKGROUND` (index.ts) duplicates `pageBackground` (theme.ts)** — two
hand-synced color constants (a "keep in sync" comment guards them).
- [ ] **L60 — `'external-url'` channel name lacks the `category:` prefix** every other IPC channel uses.
@@ -435,14 +487,16 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L63 — `audioQuality()` unknown label → silent `'0'` (best)** — the audio analog of H5.
- [ ] **L64 — `ARIA2C_ARGS` connection params hardcoded** (`-x16 -s16 -k1M`), no user control.
- [ ] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop.
- [ ] **L66 — Sidebar caption flips** "yt-dlp frontend" → "v<x>" once the version loads (text shift on boot).
- [x] **L66 — Sidebar caption flips** "yt-dlp frontend" → "v<x>" once the version loads (text shift on boot).
- [ ] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again").
- [ ] **L68 — Background-running notification fires once per process** (`notifiedBackground` never
resets) — won't remind on later window closes.
- [ ] **L69 — `settings.ts` mixes path helpers with persistence** (`getDefaultMediaDir`/
`ensureMediaDirs`/`getDownloadArchivePath` alongside the store) — split a `paths.ts`.
- [ ] **L70 — `crypto.randomUUID` fallback is effectively unreachable** in Electron/Node 26
(`typeof crypto !== 'undefined'` is always true) — dead defensive branch (see M7).
- [x] **L70 — `crypto.randomUUID` fallback is effectively unreachable** in Electron/Node 26
(`typeof crypto !== 'undefined'` is always true) — dead defensive branch (see M7). *Fixed with M7: the
branch now lives once in `newId` (kept so the fn is total in any host) and is covered by a test that stubs
`crypto` to exercise it.*
- [ ] **L71 — Settings folder inputs are `readOnly`** with only a Browse button — no way to paste a
known path.
- [ ] **L72 — History re-download drops the thumbnail** (passes only title/channel), so the
@@ -462,9 +516,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
*Round 4 (2026-06-29) — doc/code drift, build artifacts, edge cases:*
- [ ] **L80 — ROADMAP wrong file path.** [ROADMAP.md](ROADMAP.md) Phase A says the flags are
- [x] **L80 — ROADMAP wrong file path.** [ROADMAP.md](ROADMAP.md) Phase A says the flags are
emitted "in `buildArgs` (`src/main/download.ts`)" — `buildArgs` lives in `src/main/buildArgs.ts`.
- [ ] **L81 — ROADMAP internal contradiction.** Phase A: "`--split-chapters` still TODO"; Phase L:
- [x] **L81 — ROADMAP internal contradiction.** Phase A: "`--split-chapters` still TODO"; Phase L:
`[x]` split-chapters done. Phase A wasn't updated when L landed.
- [ ] **L82 — PINCHFLAT roadmap interface sketches are stale.** [ROADMAP-PINCHFLAT.md](ROADMAP-PINCHFLAT.md)
Phase F's `Source`/`MediaItem` code blocks omit shipped fields (`videoId`, `itemCount`,
@@ -481,7 +535,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
filter change, so "Delete selected" can remove entries no longer visible (surprising).
- [ ] **L87 — History re-download ignores original options.** `redownload` re-queues url/kind/quality
only; the post-processing `DownloadOptions` used originally are lost (current defaults apply).
- [ ] **L88 — `applyEvent('progress')` can promote a `queued` item to `downloading`** outside
- [x] **L88 — `applyEvent('progress')` can promote a `queued` item to `downloading`** outside
`pump()` — a latent concurrency edge if a progress event ever arrives before/without the launch transition.
- [ ] **L89 — Inconsistent release artifacts.** Only `0.4.1` carries `.sha256` files (hand-made) and
the version sequence skips 0.3.x — symptomatic of the manual release process behind H8.
@@ -516,8 +570,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L103 — Two busy-indicator patterns.** Fetch/Index swap the button icon to a `Spinner`; the
Settings check/update buttons keep their icon and render a *separate* adjacent `Spinner`.
- [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`.
- [ ] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and
- [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and
[store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it.
*Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local
`type MediaKind` is gone while existing `import { MediaKind } from '../store/downloads'` sites still resolve.*
- [ ] **L106 — Refresh icons used interchangeably.** `ArrowClockwiseRegular` (retry, re-download,
check-for-new, update-yt-dlp) vs `ArrowSyncRegular` (re-index, check-for-updates) for the same "redo/refresh" idea.
- [ ] **L107 — Spinner sizes mixed**`"tiny"` almost everywhere, `"extra-tiny"` for the QueueItem queued row.
@@ -548,7 +604,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L124 — `Body1` vs `Text` role overlap**`Body1` appears only in empty states/onboarding/gate; `Text` carries titles; the split isn't principled.
- [ ] **L125 — Icon sizing has two syntaxes** — px strings (`fontSize: '20px'`) on icon *containers* vs numeric `fontSize={28}` on inline icons.
- [ ] **L126 — Uneven text truncation** — some titles/metas use `nowrap`+ellipsis, others wrap; no shared truncation utility.
- [ ] **L127 — `Notification` sets no icon** — completion toasts use the default Electron icon though `getAppIconPath()` exists.
- [x] **L127 — `Notification` sets no icon** — completion toasts used the default Electron icon though `getAppIconPath()` existed. *Fixed with W13 (cached `getAppIconImage()` passed as `icon` at both notification sites).*
- [ ] **L128 — Preview seed/mock data ships in production.** The `PREVIEW` runtime check can't be
tree-shaken, so each store's seed arrays + fake ticker are bundled into the Electron renderer.
- [ ] **L129 — `Hint align` is silently ignored for left/right placements** yet callers still pass it.
@@ -569,9 +625,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L136 — Incognito leaks outside history.** Even if the (unreachable, M6) private flag were set,
`download.ts` still writes failures to `errorlog.json` (title + URL) and shows the title in completion
notifications — the "private" promise covers only history.
- [ ] **L137 — Stuck 0% for unknown-size downloads.** `parseProgress` returns `progress: 0` when
- [x] **L137 — Stuck 0% for unknown-size downloads.** `parseProgress` returns `progress: 0` when
`total_bytes`/`_estimate` are absent (livestreams, some sites), so the bar reads 0% the whole time
instead of an indeterminate state.
instead of an indeterminate state. *Fixed: `parseProgress` now sets `sizeUnknown: !totalBytes` on
`DownloadProgress`; the store threads it onto the item and `QueueItem` renders an indeterminate bar +
"Downloading…" (instead of a frozen 0%) when it's set, reusing the existing SR7 indeterminate path.*
- [ ] **L138 — Clipboard read on every window focus.** [useClipboardLink.ts](src/renderer/src/useClipboardLink.ts)
reads the full clipboard on each `focus` (and once on mount, possibly before `clipboardWatch` has
loaded — FALLBACK is `true`). Reading all clipboard text whenever focused may surprise privacy-conscious users.
@@ -592,10 +650,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
(already cleared from the queue) gives no "already downloaded" hint (roadmap notes this as a possible extension).
- [ ] **L145 — `useClipboardLink` re-offers a dismissed link after a tab switch.** `lastSeen` is
per-hook-instance; switching Downloads↔Library remounts it, so a previously-dismissed clipboard link is offered again.
- [ ] **L146 — `parseTrimSections` accepts malformed multi-colon times.** The `\d+(?::\d{1,2})*` pattern
- [x] **L146 — `parseTrimSections` accepts malformed multi-colon times.** The `\d+(?::\d{1,2})*` pattern
passes tokens like `1:2:3:4-5:6:7:8`, which then reach yt-dlp's `--download-sections` and fail there
rather than being rejected up front.
- [ ] **L147 — macOS branches in a Windows-only app.** `app.on('activate')` and the
- [x] **L147 — macOS branches in a Windows-only app.** `app.on('activate')` and the
`process.platform !== 'darwin'` guard in `window-all-closed` are dead on Windows — boilerplate that implies multi-platform support the app doesn't ship.
- [ ] **L148 — `clearFinished`/remove can free a slot before the killed process exits.** Removing a
just-canceled item lets `pump()` launch into the "freed" slot while `taskkill` is still tearing down the prior tree — a brief window over the concurrency cap.
@@ -612,18 +670,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
inline `width: 100%`, DownloadBar `flexGrow`. No shared search-input width.
- [ ] **L153 — Generic "Dismiss" aria-labels.** Three close buttons (DownloadBar suggestion + dup,
Library suggestion) all read just "Dismiss" — ambiguous to a screen reader. Name what's dismissed.
- [ ] **L154 — Instructional text in an aria-label.** Sidebar theme-cycle button is
- [x] **L154 — Instructional text in an aria-label.** Sidebar theme-cycle button is
`aria-label="Theme: Dark. Click to change."` — "Click to change" is UI instruction, not a name.
- [ ] **L155 — Accent swatches aren't a `radiogroup`.** They're a single-select set rendered as plain
buttons with `aria-pressed`, while the Theme controls (also single-select) use `role="radiogroup"`/`radio`.
Inconsistent single-select a11y pattern.
- [ ] **L156 — `datetime-local` schedule has no `min`.** Past times are selectable, then silently download
- [x] **L156 — `datetime-local` schedule has no `min`.** Past times are selectable, then silently download
now (extends L57). Add `min={now}`.
- [ ] **L157 — `removeSource` doesn't cancel in-flight downloads.** Removing a source while its videos are
downloading lets them finish into the removed source's folders; `markDownloaded` then no-ops on the gone item.
- [ ] **L158 — Drag highlight flickers.** `onDragLeave={() => setDragActive(false)}` fires when the cursor
crosses child elements (no enter/leave depth counter), so the dashed-outline blinks during a drag.
- [ ] **L159 — Dead fallback branch.** `copyErrorReport` falls back to "No errors logged." but its button
- [x] **L159 — Dead fallback branch.** `copyErrorReport` falls back to "No errors logged." but its button
is disabled when there are no entries, so the fallback is unreachable.
- [ ] **L160 — One-off inline margins.** `marginTop` literals (Onboarding 2px, QueueItem 4px, SettingsView
8px) instead of style classes (extends L5).
@@ -647,16 +705,16 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L166 — ETA has no hour rollover.** `fmtEta` (download.ts/downloads.ts) and `formatEta`
(queueStats) emit `M:SS` only, so a 2-hour ETA renders as **"120:00"**, whereas `fmtDuration`
(indexerCore) correctly rolls to `H:MM:SS`. Unify on the hour-aware format.
- [ ] **L167 — `PROGRESS_TEMPLATE` is exported but used only inside `buildArgs.ts`** — a superfluous
- [x] **L167 — `PROGRESS_TEMPLATE` is exported but used only inside `buildArgs.ts`** — a superfluous
public export (download.ts parses the `prog|` lines independently). Make it module-private.
*Round 9 (2026-06-29) — type-safety & build config:*
- [ ] **L168 — `noUncheckedIndexedAccess` is off.** Array/index/tuple access is typed as always-defined,
- [x] **L168 — `noUncheckedIndexedAccess` is off.** Array/index/tuple access is typed as always-defined,
so the exact edge cases already filed compile clean: the `split('|') as [MediaKind, string]` cast
(L91/CL2) and `parseProgress`'s positional destructure of a possibly-short `split('|')`. Enabling it
would surface them at build time.
- [ ] **L169 — `noFallthroughCasesInSwitch` is off.** The large `setSettings` switch and `applyEvent`
- [x] **L169 — `noFallthroughCasesInSwitch` is off.** The large `setSettings` switch and `applyEvent`
switch aren't fallthrough-guarded; a missing `break`/`return` wouldn't be caught.
- [ ] **L170 — No source maps in production.** `sourceMap: false` (toolkit) and no `build.sourcemap` in
`electron.vite.config.ts`, so field crash stack traces are unmapped — combined with no logging (CC8/M29),
@@ -680,7 +738,7 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
### Layout, spacing & width
- [ ] **UI1 — No shared content width.** SettingsView is `maxWidth: 640px` ([SettingsView.tsx](src/renderer/src/components/SettingsView.tsx)),
- [x] **UI1 — No shared content width.** SettingsView is `maxWidth: 640px` ([SettingsView.tsx](src/renderer/src/components/SettingsView.tsx)),
but Downloads/Library/History/Terminal are full-width; Onboarding card is 460px, CommandPalette
560px. On a wide window Settings is a narrow column while siblings stretch edge-to-edge.
**Standard:** one reading-width token (e.g. 720px) applied by a shared `Screen` wrapper, or commit to full-width everywhere.
@@ -700,15 +758,15 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
Library rowThumb `Small`, DownloadBar previewThumb `Medium`. **Standard:** one thumbnail radius (Medium/10px). *(Distinct from L9, which is thumbnail pixel dimensions.)*
- [ ] **UI7 — Card radius (XLarge vs Large) has no stated rule.** Top-level cards use XLarge (16),
list rows Large (12), but it's applied by feel. **Standard:** document surface tiers — page-card XLarge, list-item Large, control Medium — and apply uniformly.
- [ ] **UI8 — Circular shapes mix token and literal.** `borderRadiusCircular` (Library pill/watchBadge)
- [x] **UI8 — Circular shapes mix token and literal.** `borderRadiusCircular` (Library pill/watchBadge)
vs literal `'50%'` (SettingsView swatch, History emptyBadge). **Standard:** always the token.
### Typography
- [ ] **UI9 — Screen titles are inconsistent.** Most screens use `<Subtitle2>` (Downloads "Queue",
- [x] **UI9 — Screen titles are inconsistent.** Most screens use `<Subtitle2>` (Downloads "Queue",
Library, Terminal, Settings cards); Onboarding uses `<Title2>`; **History has no title at all**
(just a count). **Standard:** one page-title style on every screen.
- [ ] **UI10 — Only Library has a screen subtitle/description.** Others jump straight to content.
- [x] **UI10 — Only Library has a screen subtitle/description.** Others jump straight to content.
**Standard:** a consistent header block (title + optional one-line description).
- [ ] **UI11 — Semantic type ramp vs ad-hoc px.** Sidebar `brandName`/`navItem` and Library
`watchBadge`/`pill` set raw `fontSizeBaseXXX`; `sectionIcon`/`mark` use literal `'20px'`/`'26px'`.
@@ -723,17 +781,17 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
### Buttons & controls
- [ ] **UI14 — Two hand-rolled segmented controls.** DownloadBar kind toggle (`segment`, padding
- [x] **UI14 — Two hand-rolled segmented controls.** DownloadBar kind toggle (`segment`, padding
`7px 16px`) and Sidebar theme toggle (`themeSeg`, padding `7px 4px`) are separate implementations
with different padding/markup. **Standard:** one shared `SegmentedControl`.
- [ ] **UI15 — Raw `<button>`s alongside Fluent `<Button>`.** Sidebar navItem/iconBtn/themeSeg,
- [x] **UI15 — Raw `<button>`s alongside Fluent `<Button>`.** Sidebar navItem/iconBtn/themeSeg,
DownloadBar segment/plKindBtn, CommandPalette item, and Library `groupHead` (a `role="button"` div)
are bespoke. **Standard:** wrap recurring patterns so hover/focus/disabled are uniform (ties to L5/M15).
- [ ] **UI16 — Button size hierarchy isn't applied uniformly.** DownloadBar `large`; Library "Index"
`large` but its toolbar `small`; History all `small`; Settings mostly default. **Standard:** size-by-role (screen primary = large; secondary = medium; row actions = small).
- [ ] **UI17 — `appearance="secondary"` used in only two spots** (Library "Check for new", Terminal
"Stop") while every other non-primary button is `subtle`. **Standard:** pick subtle *or* secondary as the standard non-primary appearance.
- [ ] **UI18 — Two status-chip systems.** QueueItem uses Fluent `Badge`; LibraryView uses custom
- [x] **UI18 — Two status-chip systems.** QueueItem uses Fluent `Badge`; LibraryView uses custom
color `pill` spans for the same item-status concept (and labels differ — see M8). **Standard:** one shared status-chip component + label map.
- [ ] **UI19 — Suggestion-banner CSS duplicated.** Identical `suggestion`/`suggestionText` styles in
DownloadBar and LibraryView. **Standard:** a shared `LinkSuggestion` component (also kills drift).
@@ -773,13 +831,13 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
- [ ] **UI27 — Command palette uses JS hover, not CSS.** Items highlight via `onMouseEnter` setting
`itemActive` (no `:hover`), unlike every other list, and expose no `aria-selected`/active-descendant,
so the highlight is invisible to screen readers. **Standard:** CSS `:hover` + listbox/option semantics.
- [ ] **UI28 — Command palette input has no focus ring.** The search `<input>` sets `outline: 'none'`
- [x] **UI28 — Command palette input has no focus ring.** The search `<input>` sets `outline: 'none'`
with no replacement. **Standard:** a visible focus ring (Fluent stroke token). *(a11y)*
- [ ] **UI29 — Fragmented focus treatment.** Fluent ring (Fluent controls) vs custom border (Select)
- [x] **UI29 — Fragmented focus treatment.** Fluent ring (Fluent controls) vs custom border (Select)
vs removed (palette input) vs UA default (Sidebar buttons, segments, and Library `cardHead`/`groupHead`
which are `role="button" tabIndex=0` with **no `:focus-visible`** → invisible keyboard focus).
**Standard:** one focus-ring style on all interactive elements, custom and native. *(a11y; extends L14)*
- [ ] **UI30 — Hand-rolled radiogroups aren't arrow-navigable.** DownloadBar kind and Sidebar theme
- [x] **UI30 — Hand-rolled radiogroups aren't arrow-navigable.** DownloadBar kind and Sidebar theme
use `role="radiogroup"`/`radio` but implement click only — no ←/→ roving focus a radiogroup implies.
**Standard:** roving-tabindex arrow keys, or Fluent's RadioGroup. *(a11y)*
- [ ] **UI31 — `Select` can't be disabled.** The native-`<select>` wrapper exposes no `disabled` prop,
@@ -825,7 +883,7 @@ root cause is already filed.
nothing signposts that. "Index" reads like "Download" but only catalogs — you must then expand,
select, and Download. **Fix:** rename to "Add channel/playlist," and after indexing surface a clear
"Download N videos" next step; detect channel URLs in the Downloads bar and suggest the Library.
- [ ] **UX4 — Destructive actions have no confirmation and no undo** (L13). "Clear history,"
- [x] **UX4 — Destructive actions have no confirmation and no undo** (L13). "Clear history,"
"Clear log," "Remove source" (deletes an entire indexed channel + items), and "Delete selected"
are all one click. A user exploring can wipe data irreversibly. **Fix:** confirm destructive/bulk
deletes (or offer an undo toast).
@@ -898,34 +956,34 @@ DPI handled by Chromium). The deviations:
### Window behavior & resizing
- [ ] **W1 — No minimum window size.** `createWindow` sets `width/height` but no `minWidth`/`minHeight`
- [x] **W1 — No minimum window size.** `createWindow` sets `width/height` but no `minWidth`/`minHeight`
([index.ts](src/main/index.ts)), so the window can be dragged down to a few pixels and the layout
(212px sidebar + content) breaks. **Standard:** set a sensible min (e.g. 640×480).
- [ ] **W2 — Window placement isn't persisted** (extends UX19): size, position, **maximized state**, and
**which monitor** are all forgotten — it reopens 920×700 on the primary display every launch.
**Standard:** persist & restore window placement per Windows app convention (e.g. `electron-window-state`).
- [ ] **W3 — Title bar doesn't follow the in-app theme.** `nativeTheme.themeSource` is deliberately never
- [x] **W3 — Title bar doesn't follow the in-app theme.** `nativeTheme.themeSource` is deliberately never
set (index.ts:97), so the OS-drawn caption follows the *OS* theme. Choosing an explicit in-app **dark**
theme on a **light** OS leaves a light title bar on a dark app (and vice-versa). **Standard:** set
`nativeTheme.themeSource` to the resolved mode, or use `titleBarOverlay` with themed colors.
### Dialogs & file pickers
- [ ] **W4 — Text fields have no Cut/Copy/Paste context menu.** Electron adds no default editing menu and
- [x] **W4 — Text fields have no Cut/Copy/Paste context menu.** Electron adds no default editing menu and
the app registers no `context-menu` handler, so right-clicking any input/textarea (URL, search, terminal
args, template fields) shows nothing — a basic Windows text-editing affordance is missing. **Standard:**
wire a standard editing context menu (and spellcheck suggestions for textareas).
- [ ] **W5 — File pickers don't open at the current value.** `chooseFolder` sets no `defaultPath`, so the
- [x] **W5 — File pickers don't open at the current value.** `chooseFolder` sets no `defaultPath`, so the
folder dialog opens at a default location instead of the currently-configured Video/Audio folder.
**Standard:** seed the picker with the current path. *(Native dialogs themselves are correct — good.)*
- [ ] **W6 — Cookie sign-in is a separate taskbar window.** `openCookieLoginWindow` creates a
- [x] **W6 — Cookie sign-in is a separate taskbar window.** `openCookieLoginWindow` creates a
`BrowserWindow` with no `parent`/`modal` ([cookies.ts](src/main/cookies.ts)), so it appears as a second
AeroFetch taskbar button rather than a child/modal dialog. **Standard:** `parent: mainWindow` (+ `modal`
if appropriate) so it groups under the app.
### Keyboard, context menus & focus
- [ ] **W7 — Lists have no keyboard navigation.** The queue, history, and library lists can't be arrowed
- [x] **W7 — Lists have no keyboard navigation.** The queue, history, and library lists can't be arrowed
through, **Delete** doesn't remove the focused/selected item, and there's no **Ctrl+A** select-all —
all standard Windows list behaviors. **Standard:** roving focus + Delete/Ctrl+A on list surfaces. *(a11y; see also UI30)*
- [ ] **W8 — No standard accelerators.** No **Ctrl+,** (Settings), **F1** (help), or **F5** (refresh);
@@ -947,9 +1005,16 @@ DPI handled by Chromium). The deviations:
- [ ] **W12 — Fallback tray icon is single-resolution.** The embedded fallback is a 32×32 PNG, so on
150%/200% DPI the tray glyph is blurry when the real multi-size `.ico` is absent. **Standard:** a
multi-size fallback (or rely only on the `.ico`).
- [ ] **W13 — Notifications set no icon.** `new Notification({title, body})` omits `icon`; on the **portable**
build (no installed AUMID shortcut) Windows toasts may show a generic icon. **Standard:** pass the app icon. *(refines L127)*
- [ ] **W14 — App/notification icon is a placeholder** (M3-orig, deferred) — a designed asset is still wanted pre-v1.0.
- [x] **W13 — Notifications set no icon.** `new Notification({title, body})` omitted `icon`; on the **portable**
build (no installed AUMID shortcut) Windows toasts showed a generic icon. *Fixed: both notification sites
([download.ts](src/main/download.ts) completion/failure toast + [index.ts](src/main/index.ts) background-running
toast) now pass `icon: getAppIconImage()` — a new cached `NativeImage` helper in [binaries.ts](src/main/binaries.ts)
that loads the app `.ico` once and falls back to an empty image (OS default) if it's missing. (refines L127)*
- [x] **W14 — App/notification icon is a placeholder** (M3-orig) — *Fixed: redesigned
[icon.svg](build/icon.svg) into a proper mark (teal brand square with a top-lit gradient + soft sheen and a
bold rounded download glyph over a landing shelf), legible down to 16px; regenerated the multi-size
`build/icon.ico` (256/128/64/48/32/16) via ImageMagick. The notification-icon **wiring** (passing the asset
to `new Notification`) is also done — see W13/L127.*
### High DPI, multiple monitors, touch
@@ -962,7 +1027,7 @@ DPI handled by Chromium). The deviations:
### Dark mode & accessibility (Windows-specific)
- [ ] **W17 — No `aria-live` for status changes.** Narrator doesn't announce download progress,
- [x] **W17 — No `aria-live` for status changes.** Narrator doesn't announce download progress,
completion, or errors — there are no live regions. **Standard:** polite live regions for queue/status updates.
- [ ] **W18 — Custom colors unverified under High Contrast.** The status pills, segmented controls, accent
swatches, and thumbnail tints use explicit background colors that Windows forced-colors mode may not
@@ -975,8 +1040,8 @@ DPI handled by Chromium). The deviations:
never shows "3 downloading" or similar. **Standard:** reflect activity in the title/tooltip.
- [ ] **(OK)** Per-user no-admin install, NSIS uninstaller, `aerofetch://` registration, AppUserModelID,
tray tooltip + left-click restore + right-click menu, taskbar progress, and jump list all follow
Windows conventions. **(Gap)** unsigned binary → SmartScreen prompt (deferred, SIGNING.md), and the
default Electron menu with DevTools is still reachable (M31).
Windows conventions. **(Non-goal)** unsigned binary → SmartScreen prompt — accepted; no certificate will
be purchased (the build stays signing-ready via env vars if that ever changes — see [SIGNING.md](docs/SIGNING.md)).
---
@@ -993,7 +1058,7 @@ split — but that style is unenforced and several "do the same thing two ways"
(bare). Keys say `videoDir`/`audioDir` while the UI says "folder." Preload names diverge from main
(L92); `MediaKind` is declared twice (L105). **Standard:** booleans as `is`/`has`/`should`/`<verb>`
consistently; one term ("folder") across keys + UI; align preload↔main names; single-source shared types in `@shared`.
- [ ] **CC2 — Coding style is consistent but unenforced.** No ESLint/Prettier config, script, or dep
- [x] **CC2 — Coding style is consistent but unenforced.** No ESLint/Prettier config, script, or dep
(L44), so the (good) house style drifts only by discipline; `??` vs `||` is occasionally misused for
null checks. **Standard:** add Prettier + typescript-eslint with `lint`/`format` scripts in CI; codify the existing style.
- [ ] **CC3 — Different patterns for the same problem.** JSON persistence (M1), status chips (UI18),
@@ -1079,7 +1144,7 @@ filed. Several listed categories are **N/A** to this stack and worth recording s
**Dead / unreachable code (the real list):**
- [ ] **Safe to remove now — `getDefaultFolder`** (M2): channel + preload method + main handler + mock,
- [x] **Safe to remove now — `getDefaultFolder`** (M2): channel + preload method + main handler + mock,
**zero callers**. Pure deletion, no behavior change.
- [ ] **Decide "wire or remove" — command preview** (M5): channel + preload `previewCommand` + main
`previewCommand()` + `formatCommandLine`/`quoteForDisplay` (buildArgs, **unit-tested**) +
@@ -1094,7 +1159,7 @@ filed. Several listed categories are **N/A** to this stack and worth recording s
**New cleanliness findings:**
- [ ] **CL1 — Magic-string protocol markers duplicated across the emit/parse boundary.** `'prog|'` and
- [x] **CL1 — Magic-string protocol markers duplicated across the emit/parse boundary.** `'prog|'` and
`'path|'` are hard-coded in `buildArgs.ts` (`PROGRESS_TEMPLATE` / `--print after_move:path|…`) and again
in `download.ts` (`line.startsWith('prog|')` / `'path|'`). Change one and the other breaks silently.
**Fix:** export shared marker constants from one module.
@@ -1104,7 +1169,7 @@ filed. Several listed categories are **N/A** to this stack and worth recording s
`child` event handlers. **Fix:** extract the stdout-parse + close/error wiring (pairs with CC3's spawn-stream helper).
- [ ] **CL4 — Deep nesting: `updater.downloadAppUpdate`** — Promise → `net.request` → `response` → `data`
with nested conditionals/teardown is the hardest-to-follow block. **Fix:** extract a `streamToFile` helper.
- [ ] **CL5 — Superfluous exports.** `PROGRESS_TEMPLATE` (L167) and `getManagedBinDir` are `export`ed but
- [x] **CL5 — Superfluous exports.** `PROGRESS_TEMPLATE` (L167) and `getManagedBinDir` are `export`ed but
used only within their own module. **Fix:** make them module-private.
- [ ] **CL6 — Redundant wrappers** (minor): `MediaThumb`'s `kind === 'audio' ? 'audio' : 'video'` (L15),
`clearDir` = `update({[t]:''})`, and the store `openFile`/`showInFolder` thin wrappers around `window.api`.
@@ -1186,7 +1251,7 @@ earlier passes are listed so this is complete; and the categories that came back
### New bugs
- [ ] **B1 — No stall/idle timeout on downloads (resource leak + stuck slot).** `spawn(ytdlp, …, {
- [x] **B1 — No stall/idle timeout on downloads (resource leak + stuck slot).** `spawn(ytdlp, …, {
windowsHide: true })` ([download.ts](src/main/download.ts):306) sets **no `timeout`**, and `buildArgs`
emits no `--socket-timeout`. A hung connection means yt-dlp never exits → the `active` map entry and the
renderer's `downloading` item persist **forever**, permanently consuming a concurrency slot with no
@@ -1196,20 +1261,20 @@ earlier passes are listed so this is complete; and the categories that came back
sequential probes (each up to the 180 s `probeFlat` timeout); the UI shows "indexing…" with the Index
button disabled and **no cancel**. A large channel locks the add-source flow for minutes with no abort.
**Fix:** thread an `AbortSignal`/cancel token and a Cancel button.
- [ ] **B3 — `extractSha256` ignores the filename (wrong-hash risk).** It returns the **first** 64-hex
- [x] **B3 — `extractSha256` ignores the filename (wrong-hash risk).** It returns the **first** 64-hex
token in the file ([updater.ts](src/main/updater.ts)); a combined multi-file checksum uploaded as
`<asset>.sha256` would verify the installer against the wrong line's hash. Safe only by the one-hash-
per-asset convention. **Fix:** match the hash on the asset's filename line.
- [ ] **B4 — `probeMeta` assumes one line per `--print` field.** It does `stdout.split('\n')` → `[title,
- [x] **B4 — `probeMeta` assumes one line per `--print` field.** It does `stdout.split('\n')` → `[title,
uploader, duration]` ([download.ts](src/main/download.ts)); a title containing a newline shifts channel
and duration by a line. **Fix:** use a single `--print` with an unlikely delimiter, or `-J`.
- [ ] **B5 — Missing status guard on the `meta` event.** Between `cancel()` and the child's `close`,
- [x] **B5 — Missing status guard on the `meta` event.** Between `cancel()` and the child's `close`,
`active.has(id)` is briefly true, so `probeMeta` can `send` a `meta` event for a just-canceled item;
`applyEvent`'s `meta` case (unlike `progress`/`done`/`error`) has **no canceled guard**, so it updates a
canceled item's title. Harmless today, but the asymmetry is a latent bug. **Fix:** guard `meta` like the others.
- [ ] **B6 — App-update download has no cancel.** Once `downloadAppUpdate` starts there's no user abort —
only completion or the idle timeout stops it; the progress UI offers no Cancel. **Fix:** expose cancel (abort the request).
- [ ] **B7 — Cookie-login promise can never resolve if the window is destroyed without `close`.**
- [x] **B7 — Cookie-login promise can never resolve if the window is destroyed without `close`.**
`openCookieLoginWindow` resolves only via the `close`→`exportAndResolve` path; a `destroy()` (or a
`closed` without `close`) would leave `pendingResolvers` pending forever. Not triggered by current code,
but a latent never-resolve. **Fix:** also resolve/reject on `closed`.
@@ -1245,15 +1310,15 @@ A fresh lens: what happens when the *environment* fails (disk full, permission d
killed mid-write, huge inputs) rather than when the logic is wrong. These are robustness findings, not
cosmetics. `R` IDs.
- [ ] **R1 — JSON stores are not written atomically (corruption on crash/power-loss).** The hand-rolled
- [x] **R1 — JSON stores are not written atomically (corruption on crash/power-loss).** The hand-rolled
stores (history/errorlog/templates/sources/media-items) persist via `writeFileSync(file, JSON.stringify…)`
— not write-temp-then-rename. A crash/power-cut mid-write truncates the file; the next read hits a parse
error and returns `[]`. `electron-store` (settings) *is* atomic, so durability is inconsistent across the
app. **Fix:** an atomic write (temp + rename, or `write-file-atomic`) in the shared `jsonStore` (SIMP1).
- [ ] **R2 — A corrupt/invalid store silently loses all data.** `readJsonArray`/`listHistory` etc. catch a
- [x] **R2 — A corrupt/invalid store silently loses all data.** `readJsonArray`/`listHistory` etc. catch a
parse error and return `[]` with no warning, backup, or recovery — one bad byte wipes the user's history/
sources/templates from their perspective. **Fix:** on parse failure, back up the bad file (`.corrupt`) and surface a notice.
- [ ] **R3 — O(n) full-file rewrite per download completion (≈O(n²) per channel), synchronously on the
- [x] **R3 — O(n) full-file rewrite per download completion (≈O(n²) per channel), synchronously on the
main thread.** `setMediaItemDownloaded` ([sources.ts](src/main/sources.ts):126) reads + parses the entire
`media-items.json` (up to `MAX_ITEMS` = 20,000) and rewrites the whole file on **every** completion, and
`addHistory` rewrites all of `history.json` likewise. Downloading a 1,000-video channel ⇒ ~1,000 full
@@ -1263,7 +1328,7 @@ cosmetics. `R` IDs.
deletes the partial; canceled items vanish from the UI while `.part`/fragment files accumulate in the
output folder. (App crash mid-download likewise — the queue isn't persisted to resume, M4.) **Fix:**
clean up the partial on cancel, or surface/offer-resume for orphans.
- [ ] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch;
- [x] **R5 — Settings write failure is unhandled.** `setSettings` calls `store.set(...)` with no try/catch;
on disk-full/read-only `electron-store` throws → the IPC rejects → the renderer's `setSettings().catch(()
=> {})` swallows it while the optimistic UI keeps the change (the disk-full form of M34). **Fix:** catch and report.
- [ ] **R6 — Silent data loss on any store write failure.** A completed download that can't be recorded
@@ -1288,13 +1353,13 @@ the end so the picture is complete. **Verified-clean first:** card/label/button
### Poor / risky defaults (first-run impressions)
- [ ] **SR1 — Theme defaults to `'light'`, not `'system'`.** A dark-mode Windows user gets a bright white
- [x] **SR1 — Theme defaults to `'light'`, not `'system'`.** A dark-mode Windows user gets a bright white
app on first launch despite the app fully supporting "follow system." Single most-noticeable first-run
miss. **Fix:** default `theme: 'system'`.
- [ ] **SR2 — `ytdlpChannel` defaults to `'nightly'`.** The app ships auto-pulling yt-dlp's daily,
- [x] **SR2 — `ytdlpChannel` defaults to `'nightly'`.** The app ships auto-pulling yt-dlp's daily,
less-tested build (auto-update also on by default); one nightly regression breaks downloads for everyone.
Defensible for chasing YouTube changes, but a risky default for a commercial release. **Fix:** consider `stable` as the shipped default.
- [ ] **SR3 — `autoDownloadNew` defaults to `true`.** The moment a user marks a channel "watched," new
- [x] **SR3 — `autoDownloadNew` defaults to `true`.** The moment a user marks a channel "watched," new
uploads auto-download with no per-event consent — can quietly fill a disk. **Fix:** default off (let watching be "notify," downloading be opt-in).
- [ ] **SR4 — `clipboardWatch` defaults to `true`.** The app reads the clipboard on every window focus from
first launch (R7/L138). A privacy-conscious default would be off or first-run-prompted. **Fix:** default off, or ask on first run.
@@ -1304,10 +1369,10 @@ the end so the picture is complete. **Verified-clean first:** card/label/button
### Stuck states & progress glitches
- [ ] **SR6 — "Resolving…" can stick forever.** A queued item's channel shows the `'Resolving…'`
- [x] **SR6 — "Resolving…" can stick forever.** A queued item's channel shows the `'Resolving…'`
placeholder until a `meta` event arrives; if metadata never resolves (error before meta, or no channel
in the probe), the row reads "Resolving… • 1080p • Video" permanently — looks hung. **Fix:** clear the placeholder on error / when meta fails.
- [ ] **SR7 — Progress bar visibly resets mid-download.** A video+audio merge downloads as two streams, so
- [x] **SR7 — Progress bar visibly resets mid-download.** A video+audio merge downloads as two streams, so
the bar fills 0→100%, then resets to 0→100% again before muxing — looks like a glitch/restart to the
user. **Fix:** weight the two phases (e.g. 050% / 50100%) or show a "merging…" state.
- [ ] **SR8 — Window title never reflects state** (L108/W19): always the static "AeroFetch," so the taskbar
@@ -1323,16 +1388,16 @@ the end so the picture is complete. **Verified-clean first:** card/label/button
### Also reduces perceived quality (already filed — listed for completeness)
- Placeholder (non-designed) app + notification icon (W14/L127); onboarding has no folder picker / feels
thin (UX5/UX22); no boot loading state + one-frame theme flash (UX26); destructive actions have no
confirmation (UX4/L13); refresh-icon mismatch ArrowClockwise vs ArrowSync (L106) and Regular/Filled brand
icon (UI13); only the sidebar animates, everything else snaps (UI26); stuck 0% bar for unknown-size
downloads (L137); separator glyph/spacing inconsistency ``/`·` (L164); the command palette is
undiscoverable (UX21); SmartScreen prompt on the unsigned build (SIGNING.md).
- Onboarding has no folder picker / feels thin (UX5/UX22); no boot loading state + one-frame theme flash
(UX26); refresh-icon mismatch ArrowClockwise vs ArrowSync (L106) and Regular/Filled brand icon (UI13); only
the sidebar animates, everything else snaps (UI26); separator glyph/spacing inconsistency ``/`·` (L164);
the command palette is undiscoverable (UX21). *(The unsigned-build SmartScreen prompt is accepted as a
non-goal — no certificate.)*
**Ship-blockers (highest perceived-quality impact):** SR1 (light-on-dark first launch), SR7 (progress
visibly restarting), SR6 ("Resolving…" stuck), W14 (placeholder icon), and the unsigned-build SmartScreen
prompt. None are hard fixes; together they're most of the "this feels finished" gap.
visibly restarting), SR6 ("Resolving…" stuck), and W14 (placeholder icon) — **all now resolved.** The
unsigned-build SmartScreen prompt is accepted as a non-goal (no certificate). Together these were most of
the "this feels finished" gap.
---
@@ -1343,11 +1408,11 @@ acceptable first:** memoized `QueueItem`s don't re-render when their item is unc
`map` preserves references for untouched items); the queue + large library lists are virtualized;
`electron-store` is constructed lazily. The issues:
- [ ] **PERF1 — `getSettings()` is heavy and on every hot path.** It runs per download spawn (twice — in
- [x] **PERF1 — `getSettings()` is heavy and on every hot path.** It runs per download spawn (twice — in
`buildCommand` and the `maxConcurrent` check), per completion (`notify`), and on the system-theme bridge;
each call re-reads the whole store and, for users who've configured a proxy/PO-token/update-token, runs
up to 3 `safeStorage.decryptString` (DPAPI) calls. **Fix:** cache the decrypted settings, invalidate on write.
- [ ] **PERF2 — `templates.json` is read+parsed on every download.** `resolveExtraArgs` evaluates
- [x] **PERF2 — `templates.json` is read+parsed on every download.** `resolveExtraArgs` evaluates
`templates: listTemplates()` eagerly as an argument ([download.ts](src/main/download.ts):182) *before*
`selectExtraArgs` checks `customCommandEnabled`, so the file is read even when custom commands are off.
**Fix:** pass a lazy getter, or short-circuit on the gate first.
@@ -1363,7 +1428,7 @@ acceptable first:** memoized `QueueItem`s don't re-render when their item is unc
re-measures/cache churn). **Fix:** `useCallback`/hoist them.
- [ ] **PERF6 — Per-thumbnail store subscriptions.** Each `MediaThumb` calls `useResolvedDark()` (two
store subscriptions) (L162); a long list creates N×2 subscriptions. **Fix:** resolve once, pass as a prop.
- [ ] **PERF7 — (ref R3) O(n²) media-items rewrites** dominate the main-process cost when downloading a
- [x] **PERF7 — (ref R3) O(n²) media-items rewrites** dominate the main-process cost when downloading a
channel; closed by the cached/atomic `jsonStore` (SIMP1) — the single highest-value perf fix.
- [ ] **PERF8 — No code-splitting.** All views (incl. the rarely-used Terminal/Settings) load up front in
one renderer bundle alongside Fluent UI + React; software rendering (W15) compounds first-paint cost.
@@ -1402,5 +1467,7 @@ Security & correctness audit (2026-06-23):
- PO-token WebView auto-minting — only the `--extractor-args` plumbing shipped.
- Taskbar overlay badge (needs a drawn `NativeImage`).
- i18n — deliberately deferred (note: UI strings are hard-coded throughout).
- Code signing — build is signing-ready; needs a purchased cert.
- Code signing — **non-goal**: no certificate will be purchased, so builds ship unsigned and the SmartScreen
prompt is accepted. The build remains signing-ready via `CSC_LINK`/`CSC_KEY_PASSWORD` env vars if this ever
changes — see [SIGNING.md](docs/SIGNING.md).
- Live smoke-test of OS-level wiring (tray/jump list/scheduled sync/`aerofetch://`).
+41 -33
View File
@@ -32,9 +32,10 @@ filename template · yt-dlp version check · NSIS installer + portable build.
A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing
choices end-to-end. Persisted defaults are editable in **Settings → Format &
post-processing** via a reusable `DownloadOptionsForm` component, which the download
bar also hosts in a collapsible per-download **Options** panel. The main process emits
the flags in `buildArgs` (`src/main/download.ts`). All items verified in the UI preview
post-processing** via a reusable `DownloadOptionsForm` component. (The per-download
**Options** panel in the download bar is plumbed end-to-end — `StartDownloadOptions.options`/
`extraArgs` — but not yet surfaced in the bar UI; see audit UX1/M5.) The main process emits
the flags in `buildArgs` (`src/main/buildArgs.ts`). All items verified in the UI preview
(typecheck clean). **Real-download smoke test ✅ (2026-06-23):** crop-to-square cover,
audio re-encode (opus), video container + codec preference (mkv + vp9 merge), subtitle
embed (→ mov_text), chapter embed, and SponsorBlock-remove (output duration shrinks by the
@@ -55,7 +56,7 @@ found and fixed a real shipping bug — the bundled `ffprobe.exe` was missing (s
- [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) +
preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without
overriding the requested resolution.
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` still TODO.)
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` shipped in Phase L.)
- [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail
post-processor args. *Smoke-tested ✅: the embedded cover comes out a perfect square
(360×360) — the `--ppa` crop recipe survives yt-dlp's shlex split + ffmpeg's filtergraph.*
@@ -126,10 +127,12 @@ extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write
the Custom commands card). The **"run custom command" mode** is
`Settings.customCommandEnabled`, applied to every new download unless overridden
per-download in the download bar's Custom command panel.
- [x] **Command preview.** `IpcChannels.commandPreview` `previewCommand()`
(`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path
a real download would take, then renders it with `formatCommandLine`. Surfaced as
a **Preview command** button in the download bar, with a Copy button.
- [ ] **Command preview (plumbed, not surfaced).** `IpcChannels.commandPreview`
`previewCommand()` (`src/main/download.ts`) builds the exact argv via the same
`buildCommand()` path a real download would take, then renders it with
`formatCommandLine`. The **Preview command** button is NOT yet in the download bar
only the main/preload/buildArgs chain exists. Wire it into the per-download Options
panel or remove the chain (audit M5/UX1).
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
next to the existing version check, with a channel picker + result output.
@@ -154,9 +157,12 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
- [x] **Private / incognito mode** — a download bar toggle (`DownloadBar.tsx`, sticky like an
incognito tab) sets `DownloadItem.incognito`, which `store/downloads.ts` checks before
ever calling `useHistory().add(...)`. Queue items show an eye-off badge when private.
- [ ] **Private / incognito mode (plumbed, not surfaced).** `DownloadItem.incognito` flows
through `buildItem` → history-skip → the QueueItem "Private" badge, and
`store/downloads.ts` checks it before ever calling `useHistory().add(...)`. But no UI
control sets `incognito: true` yet — the download-bar toggle is not wired (audit M6/UX1).
Note `download.ts` would still log a failure / show a completion title for a private item,
so the "private" promise currently covers history only (audit L136).
- [x] **Backup / restore** settings + templates (export / import JSON). New
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
@@ -171,9 +177,9 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
Some items are Android-specific in Seal and adapted to Windows here.
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
Theme select (Light / Dark / **Follow system**) and four accent presets — Toffee
(original), Slate, Evergreen, Lavender — each a 16-stop `BrandVariants` ramp sharing
toffee's lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
Theme select (Light / Dark / **Follow system**) and four accent presets — Rose,
Coral, Amber, Teal ("Sunset-to-sea", default Teal) — each a 16-stop `BrandVariants`
ramp sharing one lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
the renderer over a new `system-theme:update` channel into
@@ -184,8 +190,8 @@ Some items are Android-specific in Seal and adapted to Windows here.
Chromium's native `forced-colors` handling since nothing in the app sets
`forced-color-adjust: none`. The sidebar's quick toggle still works under "system" — it
sets an explicit light/dark away from whatever's currently resolved, breaking out of
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee,
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Teal,
switching to Rose/Coral/Amber, Light/Dark/Follow-system, and the sidebar
toggle's break-out-of-system behavior all checked visually.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target:
@@ -285,14 +291,11 @@ YTDLnis's signature differentiator. The keyframe plumbing already exists in Aero
keeps the full file and writes per-chapter files via its default `chapter:` template.
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
still pending.*
- [ ] **Metadata editing before download.** Change title / author / artist pre-download
(YTDLnis: "modify metadata such as title and author"). `--parse-metadata` /
`--replace-in-metadata`; fits the audio path. Good for building a clean music library
where the source title is messy. *Held: the exact set-a-literal recipe is fragile
(`--parse-metadata FROM:TO` splits on a colon that titles often contain;
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
flag.*
- [x] **Metadata editing before download.** Change title / author / artist pre-download
(YTDLnis: "modify metadata such as title and author"). Uses `--replace-in-metadata FIELD ^.*$ VALUE`
(not `--parse-metadata FROM:TO` which splits on colons). Only backslashes need escaping in the
replacement string. Title, Artist, Album inputs in `DownloadOptionsForm.tsx`; setting any override
implicitly enables `--embed-metadata`. Unit-tested in `buildArgs.test.ts`.
## Phase M — Queue & daily-use UX ✅ COMPLETE
@@ -384,9 +387,10 @@ All in the main process. typecheck + test + `npm run build` clean.
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
assets this repo doesn't have tooling to generate here.*
- [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
*Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the
taskbar progress bar's `error` mode already signals failures.*
- [x] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
Green dot when downloading, red dot on error, cleared when idle. Pure-JS PNG generator in
`src/main/badge.ts` (Buffer + zlib, no new deps); badgeCount threaded through the existing
`taskbarProgress` IPC channel from `summarizeQueue`.
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
live by matching each card's text (DOM `display` toggle keyed off the root's children — no
per-card refactor), with a "no settings match" hint.*
@@ -400,17 +404,21 @@ build clean); the parts that inherently need a human or a paid cert are delivere
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
**Deferred: automatic WebView token minting** (the YTDLnis headline) — the hard part;
this is the argv plumbing it would feed. Cookies remain the easier bot-check fix.
**Automatic WebView token minting shipped:** `src/main/poToken.ts` opens a YouTube video
page in a hardened BrowserWindow (shared login session), injects an async poll for
`ytInitialPlayerResponse.serviceIntegrityDimensions.poToken`, saves the result encrypted
via `safeStorage`. "Fetch" button in Settings → Network triggers it via `youtube:po-token-mint`
IPC. Falls back gracefully (null) if YouTube's page structure changes.
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
steps + expected results. **Still needs a human to run it.**
- [x] **Code signing.** *Build is signing-ready: electron-builder picks the cert up from
`CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change (unset = unsigned, as today). Documented
in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV, local + CI, HSM/EV hook, signature
verification). **Actual signing needs a purchased certificate.***
- [x] **Code signing — non-goal.** *Decision: no certificate will be purchased, so builds ship
unsigned and the SmartScreen first-run prompt is accepted. The build stays signing-ready —
electron-builder picks the cert up from `CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change
(unset = unsigned, as today), documented in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV,
local + CI, HSM/EV hook, signature verification) — if that decision is ever reversed.*
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
picker that doesn't change anything would be misleading; doing it properly means wiring an
i18n library + extracting every string, with translations arriving incrementally. Tracked,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+23 -9
View File
@@ -1,12 +1,26 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- Placeholder app icon (audit M3). Mirrors the in-app brand mark in
Onboarding.tsx: a white download arrow on a rounded square in the default
"teal" accent (theme.ts teal[80] = colorBrandBackground in light mode).
Replace with a designed asset before v1.0; regenerate icon.ico from this. -->
<rect x="0" y="0" width="256" height="256" rx="52" ry="52" fill="#148185"/>
<g fill="#ffffff">
<rect x="112" y="46" width="32" height="88" rx="12" ry="12"/>
<polygon points="82,114 174,114 128,176"/>
<rect x="66" y="190" width="124" height="20" rx="10" ry="10"/>
<!-- AeroFetch app icon (audit W14). A white download glyph — shaft + chevron over
a landing shelf — on the teal brand square, with a top-lit vertical gradient
drawn from the teal accent ramp (theme.ts teal 50→90, centred on teal[80]
#148185, the light-mode colorBrandBackground) and a soft upper sheen for depth.
The bold rounded strokes stay legible down to 16px. Regenerate the multi-size
.ico after any edit:
magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1aa6ab"/>
<stop offset="1" stop-color="#0d595c"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="256" height="256" rx="56" ry="56" fill="url(#bg)"/>
<rect x="0" y="0" width="256" height="256" rx="56" ry="56" fill="url(#sheen)"/>
<g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round">
<path d="M128 56 L128 150" stroke-width="30"/>
<path d="M84 116 L128 160 L172 116" stroke-width="30"/>
<path d="M80 200 L176 200" stroke-width="26"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 699 B

After

Width:  |  Height:  |  Size: 1.4 KiB

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