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:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
+188
-121
@@ -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. 0–50% / 50–100%) 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
@@ -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,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 361 KiB |
+23
-9
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Generated
+1218
-1
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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
@@ -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 (R1–R3).
|
||||
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
@@ -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
@@ -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 (R1–R3).
|
||||
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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
|
||||
return new Promise((resolve) => {
|
||||
// Resolve schtasks from System32 by absolute path, not the bare name, so a
|
||||
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
|
||||
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
|
||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||
})
|
||||
execFile(
|
||||
getSystem32Path('schtasks.exe'),
|
||||
args,
|
||||
{ windowsHide: true },
|
||||
(err, stdout, stderr) => {
|
||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+69
-16
@@ -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
@@ -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 (R1–R3 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
@@ -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 (R1–R3).
|
||||
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
@@ -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
@@ -68,9 +68,10 @@ function updateDir(): string {
|
||||
* an "upgrade" over the same shipped version.
|
||||
*/
|
||||
function parseVersion(v: string): number[] {
|
||||
return v
|
||||
.replace(/^v/i, '')
|
||||
.split(/[-+]/)[0]
|
||||
// split() always yields at least one element; `?? ''` only satisfies the type
|
||||
// checker (noUncheckedIndexedAccess) for the [0] access.
|
||||
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
|
||||
return core
|
||||
.split('.')
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
@@ -105,9 +106,28 @@ interface GiteaRelease {
|
||||
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||
* longer run like a sha512 digest is ignored rather than sliced).
|
||||
*
|
||||
* When `fileName` is given (the installer asset's name), a multi-line / combined
|
||||
* checksum file is matched line-by-line and the hash on the line naming THIS
|
||||
* asset wins — so a `<asset>.sha256` that happens to list several files can't
|
||||
* verify the installer against the wrong line's hash (B3). Falls back to the
|
||||
* first standalone digest for bare single-hash files (no filename present).
|
||||
*/
|
||||
export function extractSha256(text: string): string | null {
|
||||
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
||||
export function extractSha256(text: string, fileName?: string): string | null {
|
||||
const hashRe = /\b[a-f0-9]{64}\b/i
|
||||
if (fileName) {
|
||||
const base = fileName.toLowerCase()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(hashRe)
|
||||
if (!m) continue
|
||||
// The filename is the last whitespace-delimited token on a sha256sum /
|
||||
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
|
||||
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
|
||||
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
|
||||
if (last.toLowerCase() === base) return m[0].toLowerCase()
|
||||
}
|
||||
}
|
||||
const m = text.match(hashRe)
|
||||
return m ? m[0].toLowerCase() : null
|
||||
}
|
||||
|
||||
@@ -200,7 +220,9 @@ function fetchTrustedText(
|
||||
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
||||
const done = (
|
||||
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
|
||||
): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
@@ -261,7 +283,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
||||
const sum = await fetchTrustedText(checksumUrl)
|
||||
let expectedSha: string | null = null
|
||||
if (sum.ok) {
|
||||
expectedSha = extractSha256(sum.text)
|
||||
expectedSha = extractSha256(sum.text, safeName)
|
||||
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||
} else if (sum.status === 404) {
|
||||
if (REQUIRE_CHECKSUM) {
|
||||
|
||||
+15
-13
@@ -32,10 +32,7 @@ export function ensureManagedYtdlp(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
|
||||
* Proves the main-process → bundled-binary → IPC path end to end.
|
||||
*/
|
||||
/** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
|
||||
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
const ytdlpPath = getYtdlpPath()
|
||||
|
||||
@@ -47,16 +44,21 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out running yt-dlp.'
|
||||
: (stderr || err.message).trim()
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
execFile(
|
||||
ytdlpPath,
|
||||
['--version'],
|
||||
{ windowsHide: true, timeout: 15_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out running yt-dlp.'
|
||||
: (stderr || err.message).trim()
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, version: stdout.trim() })
|
||||
}
|
||||
resolve({ ok: true, version: stdout.trim() })
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+24
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,7 +33,10 @@ import { sameVideo } from '../store/queueStats'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { IconButton } from './ui/IconButton'
|
||||
import { useClipboardLink, looksLikeUrl } from '../useClipboardLink'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
|
||||
function firstUrl(text: string): string | null {
|
||||
@@ -148,34 +151,6 @@ const useStyles = makeStyles({
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
},
|
||||
segmented: {
|
||||
display: 'inline-flex',
|
||||
width: 'fit-content',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
overflow: 'hidden'
|
||||
},
|
||||
segment: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
padding: '7px 16px',
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: tokens.colorBrandBackground,
|
||||
color: tokens.colorNeutralForegroundOnBrand,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorBrandBackgroundHover
|
||||
}
|
||||
},
|
||||
quality: {
|
||||
minWidth: '220px'
|
||||
},
|
||||
@@ -224,24 +199,6 @@ const useStyles = makeStyles({
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
plKindBtn: {
|
||||
flexShrink: 0,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
}
|
||||
},
|
||||
plItemLabel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -296,6 +253,14 @@ const useStyles = makeStyles({
|
||||
}
|
||||
})
|
||||
|
||||
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
|
||||
// in LOCAL time — used as the picker's `min` so a past time can't be chosen and
|
||||
// then silently download immediately (L156/L57).
|
||||
function toLocalDatetimeValue(d: Date): string {
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function DownloadBar(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
@@ -307,7 +272,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [kind, setKind] = useState<MediaKind>('video')
|
||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
|
||||
|
||||
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||
const [showTrim, setShowTrim] = useState(false)
|
||||
@@ -346,7 +311,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
const u = parseUrlFile(content)
|
||||
if (u) onUrlChange(u)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(logError('dropped .url file read'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +349,10 @@ export function DownloadBar(): React.JSX.Element {
|
||||
dismiss: dismissSuggestion,
|
||||
offer: offerLink
|
||||
} = useClipboardLink(url)
|
||||
useEffect(() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')), [offerLink])
|
||||
useEffect(
|
||||
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
|
||||
[offerLink]
|
||||
)
|
||||
|
||||
function acceptSuggestion(): void {
|
||||
const link = acceptLink()
|
||||
@@ -393,7 +361,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
|
||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||
const selectedFormat: FormatOption | undefined = usingFormats
|
||||
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
||||
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
|
||||
: undefined
|
||||
|
||||
function clearProbe(): void {
|
||||
@@ -573,7 +541,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<div className={styles.urlRow}>
|
||||
<Input
|
||||
className={styles.url}
|
||||
input={{ id: 'aerofetch-url' }}
|
||||
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
|
||||
value={url}
|
||||
onChange={(_, d) => onUrlChange(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
||||
@@ -716,19 +684,22 @@ export function DownloadBar(): React.JSX.Element {
|
||||
/>
|
||||
<Hint
|
||||
label={
|
||||
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
|
||||
effKind(e.index) === 'audio'
|
||||
? 'Audio — click for video'
|
||||
: 'Video — click for audio'
|
||||
}
|
||||
placement="top"
|
||||
align="end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.plKindBtn}
|
||||
<IconButton
|
||||
size="sm"
|
||||
style={{ flexShrink: 0 }}
|
||||
icon={
|
||||
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
|
||||
}
|
||||
onClick={() => toggleItemKind(e.index)}
|
||||
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
||||
>
|
||||
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
|
||||
</button>
|
||||
/>
|
||||
</Hint>
|
||||
</div>
|
||||
))}
|
||||
@@ -781,6 +752,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
type="datetime-local"
|
||||
className={styles.dtInput}
|
||||
value={scheduleAt}
|
||||
min={toLocalDatetimeValue(new Date())}
|
||||
onChange={(e) => setScheduleAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
@@ -792,20 +764,15 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.control}>
|
||||
<Caption1>Format</Caption1>
|
||||
<div className={styles.segmented} role="radiogroup" aria-label="Format">
|
||||
{(['video', 'audio'] as MediaKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={kind === k}
|
||||
className={mergeClasses(styles.segment, kind === k && styles.segmentActive)}
|
||||
onClick={() => onKindChange(k)}
|
||||
>
|
||||
{k === 'video' ? 'Video' : 'Audio'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl<MediaKind>
|
||||
value={kind}
|
||||
options={[
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'audio', label: 'Audio' }
|
||||
]}
|
||||
onChange={onKindChange}
|
||||
ariaLabel="Format"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.control}>
|
||||
|
||||
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
|
||||
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
|
||||
sponsor: 'Sponsor',
|
||||
intro: 'Intro / intermission',
|
||||
outro: 'Endcards / credits',
|
||||
outro: 'End cards / credits',
|
||||
selfpromo: 'Self-promotion',
|
||||
preview: 'Preview / recap',
|
||||
filler: 'Filler / tangent',
|
||||
@@ -123,7 +123,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
|
||||
<Field
|
||||
label="Preferred codec"
|
||||
hint="A preference when several formats match — not a strict filter."
|
||||
>
|
||||
<Select
|
||||
aria-label="Preferred video codec"
|
||||
value={value.preferredVideoCodec}
|
||||
@@ -144,7 +147,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
||||
<Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
|
||||
@@ -232,6 +235,28 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
label={value.embedMetadata ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Metadata overrides"
|
||||
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
|
||||
>
|
||||
<div className={styles.subGroup}>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={value.metadataTitle ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataTitle', d.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Artist"
|
||||
value={value.metadataArtist ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataArtist', d.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Album"
|
||||
value={value.metadataAlbum ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
|
||||
<Switch
|
||||
checked={value.embedThumbnail}
|
||||
|
||||
@@ -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 couldn’t 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Input,
|
||||
Body1,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from '@fluentui/react-icons'
|
||||
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
||||
import { useHistory } from '../store/history'
|
||||
import { formatWhen } from '../datetime'
|
||||
import { useResolvedDark } from '../store/systemTheme'
|
||||
import { useDownloads } from '../store/downloads'
|
||||
import { thumbColors } from '../theme'
|
||||
@@ -29,6 +31,8 @@ import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { Hint } from './Hint'
|
||||
import { Select } from './Select'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -115,7 +119,7 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
borderRadius: tokens.borderRadiusCircular,
|
||||
fontSize: '26px'
|
||||
},
|
||||
emptyHint: {
|
||||
@@ -134,19 +138,10 @@ const KIND_FILTER_OPTIONS = [
|
||||
{ value: 'audio', label: 'Audio' }
|
||||
]
|
||||
|
||||
function formatWhen(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const dayMs = 1000 * 60 * 60 * 24
|
||||
if (ts >= startOfToday) return `Today, ${time}`
|
||||
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function HistoryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const focus = useFocusStyles()
|
||||
const isDark = useResolvedDark()
|
||||
const tc = thumbColors[isDark ? 'dark' : 'light']
|
||||
const entries = useHistory((s) => s.entries)
|
||||
@@ -161,6 +156,8 @@ export function HistoryView(): React.JSX.Element {
|
||||
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
@@ -196,6 +193,7 @@ export function HistoryView(): React.JSX.Element {
|
||||
function exitSelectMode(): void {
|
||||
setSelectMode(false)
|
||||
setSelected(new Set())
|
||||
setConfirmDelete(false)
|
||||
}
|
||||
|
||||
function deleteSelected(): void {
|
||||
@@ -205,21 +203,25 @@ export function HistoryView(): React.JSX.Element {
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div
|
||||
className={styles.emptyBadge}
|
||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||
>
|
||||
<HistoryRegular />
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||
<div className={styles.empty}>
|
||||
<div
|
||||
className={styles.emptyBadge}
|
||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||
>
|
||||
<HistoryRegular />
|
||||
</div>
|
||||
<Body1>No downloads yet.</Body1>
|
||||
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
|
||||
</div>
|
||||
<Body1>No downloads yet.</Body1>
|
||||
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||
<div className={styles.header}>
|
||||
{selectMode ? (
|
||||
<>
|
||||
@@ -228,18 +230,44 @@ export function HistoryView(): React.JSX.Element {
|
||||
{allFilteredSelected ? 'Select none' : 'Select all'}
|
||||
</Button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={deleteSelected}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
|
||||
Cancel
|
||||
</Button>
|
||||
{confirmDelete ? (
|
||||
<>
|
||||
<Caption1 className={styles.count}>
|
||||
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={exitSelectMode}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -248,6 +276,7 @@ export function HistoryView(): React.JSX.Element {
|
||||
</Caption1>
|
||||
<Input
|
||||
className={styles.search}
|
||||
input={{ 'aria-label': 'Search history' }}
|
||||
size="small"
|
||||
value={query}
|
||||
onChange={(_, d) => setQuery(d.value)}
|
||||
@@ -270,9 +299,36 @@ export function HistoryView(): React.JSX.Element {
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
|
||||
Clear history
|
||||
</Button>
|
||||
{confirmClear ? (
|
||||
<>
|
||||
<Caption1 className={styles.count}>
|
||||
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
clear()
|
||||
setConfirmClear(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
Clear history
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,10 +336,33 @@ export function HistoryView(): React.JSX.Element {
|
||||
{filtered.length === 0 ? (
|
||||
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<div
|
||||
className={styles.list}
|
||||
onKeyDown={(e) => {
|
||||
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
|
||||
// select mode if needed). Scoped to the list, so it never hijacks
|
||||
// Ctrl+A in the search field, which lives in the header above.
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||
e.preventDefault()
|
||||
setSelectMode(true)
|
||||
setSelected(new Set(filtered.map((x) => x.id)))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filtered.map((h: HistoryEntry) => {
|
||||
return (
|
||||
<div key={h.id} className={styles.row}>
|
||||
<div
|
||||
key={h.id}
|
||||
className={mergeClasses(styles.row, focus.focusRing)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
// Delete removes the focused row (not when a child button is focused).
|
||||
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||
e.preventDefault()
|
||||
remove(h.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
checked={selected.has(h.id)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Text,
|
||||
@@ -33,10 +32,15 @@ import type { MediaItem, Source } from '@shared/ipc'
|
||||
import { useSources } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
||||
import { logError } from '../reportError'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { relTime } from '../datetime'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { VirtualList } from './VirtualList'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { StatusChip } from './ui/StatusChip'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -50,26 +54,13 @@ type ItemStatus = DownloadStatus | 'pending'
|
||||
* window cleanly (one virtualizer over a flat array, headers included).
|
||||
*/
|
||||
type LibRow =
|
||||
| { kind: 'header'; title: string; items: MediaItem[] }
|
||||
| { kind: 'item'; item: MediaItem }
|
||||
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
|
||||
|
||||
/** Above this many flattened rows, the item list switches to a virtualized panel. */
|
||||
const VIRTUALIZE_AT = 100
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
paused: 'Paused',
|
||||
saved: 'Saved',
|
||||
completed: 'Downloaded',
|
||||
error: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
@@ -198,27 +189,7 @@ const useStyles = makeStyles({
|
||||
textOverflow: 'ellipsis',
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 },
|
||||
pill: {
|
||||
flexShrink: 0,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: '1px 8px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
pillDownloading: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
pillCompleted: {
|
||||
backgroundColor: tokens.colorPaletteGreenBackground2,
|
||||
color: tokens.colorPaletteGreenForeground2
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: tokens.colorPaletteRedBackground2,
|
||||
color: tokens.colorPaletteRedForeground2
|
||||
}
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||
@@ -241,18 +212,10 @@ function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[
|
||||
return groups
|
||||
}
|
||||
|
||||
function relTime(ms?: number): string {
|
||||
if (!ms) return 'never'
|
||||
const mins = Math.round((Date.now() - ms) / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} d ago`
|
||||
}
|
||||
|
||||
export function LibraryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const focus = useFocusStyles()
|
||||
const sources = useSources((s) => s.sources)
|
||||
const itemsBySource = useSources((s) => s.itemsBySource)
|
||||
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
||||
@@ -271,6 +234,13 @@ export function LibraryView(): React.JSX.Element {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
|
||||
|
||||
// Reset any pending Remove confirmation when the expanded source changes so a
|
||||
// stale confirm can't reappear after navigating between sources.
|
||||
useEffect(() => {
|
||||
setConfirmRemoveId(null)
|
||||
}, [selectedSourceId])
|
||||
// Offer a freshly-copied link the way the Downloads tab does, but skip single
|
||||
// videos — a library source is a channel/playlist to sync, not a one-off.
|
||||
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
||||
@@ -287,7 +257,10 @@ export function LibraryView(): React.JSX.Element {
|
||||
// Load the current scheduled-sync (Task Scheduler) state once.
|
||||
useEffect(() => {
|
||||
if (PREVIEW) return
|
||||
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
|
||||
window.api
|
||||
.getScheduledSync()
|
||||
.then((s) => setScheduled(s.enabled))
|
||||
.catch(logError('getScheduledSync'))
|
||||
}, [])
|
||||
|
||||
async function onCheckNew(): Promise<void> {
|
||||
@@ -322,8 +295,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
const groups = useMemo(() => groupByPlaylist(items), [items])
|
||||
// A group is shown when toggled open, or auto-expanded when it's the only group
|
||||
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
|
||||
const isGroupOpen = (title: string): boolean =>
|
||||
groups.length === 1 || expandedGroups.has(title)
|
||||
const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
|
||||
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
|
||||
const flatRows = useMemo<LibRow[]>(() => {
|
||||
const rows: LibRow[] = []
|
||||
@@ -385,6 +357,9 @@ export function LibraryView(): React.JSX.Element {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const it of groupItems) {
|
||||
// Only actionable rows are selectable, so the selection count can never
|
||||
// exceed what "Download N selected" will actually queue (M36).
|
||||
if (!actionable(it)) continue
|
||||
if (on) next.add(it.id)
|
||||
else next.delete(it.id)
|
||||
}
|
||||
@@ -422,13 +397,6 @@ export function LibraryView(): React.JSX.Element {
|
||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
function pillClass(status: ItemStatus): string {
|
||||
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
||||
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
|
||||
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
|
||||
return styles.pill
|
||||
}
|
||||
|
||||
// One row of the item list — a playlist header or a video — shared by the
|
||||
// inline (small source) and virtualized (large source) render paths.
|
||||
function rowKey(row: LibRow): string {
|
||||
@@ -437,18 +405,19 @@ export function LibraryView(): React.JSX.Element {
|
||||
|
||||
function renderRow(row: LibRow): React.JSX.Element {
|
||||
if (row.kind === 'header') {
|
||||
const allOn = row.items.every((it) => selected.has(it.id))
|
||||
const open = isGroupOpen(row.title)
|
||||
const groupActionable = row.items.filter(actionable).length
|
||||
// "All on" is judged over the ACTIONABLE rows only — downloaded rows aren't
|
||||
// selectable, so they must not keep the group from reading as fully selected (M36).
|
||||
const groupActionableItems = row.items.filter(actionable)
|
||||
const groupActionable = groupActionableItems.length
|
||||
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHead}
|
||||
className={mergeClasses(styles.groupHead, focus.focusRing)}
|
||||
onClick={() => toggleGroupExpand(row.title)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
|
||||
}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
@@ -486,6 +455,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
<div className={styles.row}>
|
||||
<Checkbox
|
||||
checked={selected.has(it.id)}
|
||||
disabled={!actionable(it)}
|
||||
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
||||
aria-label={`Select ${it.title}`}
|
||||
/>
|
||||
@@ -501,23 +471,22 @@ export function LibraryView(): React.JSX.Element {
|
||||
</span>
|
||||
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
|
||||
</div>
|
||||
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
||||
<StatusChip status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Library</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
Index a channel or playlist once, then download it into organized folders.
|
||||
</Caption1>
|
||||
</div>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader
|
||||
title="Library"
|
||||
description="Index a channel or playlist once, then download it into organized folders."
|
||||
/>
|
||||
|
||||
<div className={styles.addRow}>
|
||||
<Input
|
||||
className={styles.addInput}
|
||||
input={{ 'aria-label': 'Channel or playlist URL' }}
|
||||
value={url}
|
||||
onChange={(_, d) => setUrl(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||
@@ -615,9 +584,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
styles={styles}
|
||||
source={src}
|
||||
expanded={selectedSourceId === src.id}
|
||||
onToggleExpand={() =>
|
||||
selectSource(selectedSourceId === src.id ? null : src.id)
|
||||
}
|
||||
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
|
||||
>
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.actionRow}>
|
||||
@@ -634,7 +601,11 @@ export function LibraryView(): React.JSX.Element {
|
||||
<div className={styles.actionSpacer} />
|
||||
{selected.size > 0 ? (
|
||||
<>
|
||||
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
@@ -675,14 +646,38 @@ export function LibraryView(): React.JSX.Element {
|
||||
>
|
||||
Re-index
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => removeSource(src.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{confirmRemoveId === src.id ? (
|
||||
<>
|
||||
<Caption1 className={styles.sub}>Remove {src.title}?</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
removeSource(src.id)
|
||||
setConfirmRemoveId(null)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => setConfirmRemoveId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmRemoveId(src.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
|
||||
@@ -695,7 +690,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
items={flatRows}
|
||||
style={{ height: '58vh' }}
|
||||
overscan={10}
|
||||
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
|
||||
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
|
||||
getKey={(row) => rowKey(row)}
|
||||
renderItem={(row) => renderRow(row)}
|
||||
/>
|
||||
@@ -730,10 +725,11 @@ function SourceCard({
|
||||
onToggleExpand: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const focus = useFocusStyles()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div
|
||||
className={styles.cardHead}
|
||||
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
||||
onClick={onToggleExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles } from '@fluentui/react-components'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// Visually hidden, but present for screen readers (the standard sr-only recipe).
|
||||
srOnly: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* A polite ARIA live region (W17) so Narrator announces download completions and
|
||||
* failures even when the user isn't on the Downloads tab. Subscribes to the store
|
||||
* directly and announces only terminal transitions (→ completed / error), so it
|
||||
* never chatters on progress ticks. Mounted once, near the app root.
|
||||
*/
|
||||
export function LiveRegion(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const [message, setMessage] = useState('')
|
||||
const prev = useRef<Map<string, DownloadStatus>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
function check(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||
const announcements: string[] = []
|
||||
for (const it of items) {
|
||||
if (prev.current.get(it.id) !== it.status) {
|
||||
if (it.status === 'completed') announcements.push(`Finished downloading ${it.title}`)
|
||||
else if (it.status === 'error') announcements.push(`Download failed: ${it.title}`)
|
||||
}
|
||||
}
|
||||
prev.current = new Map(items.map((i) => [i.id, i.status]))
|
||||
if (announcements.length > 0) setMessage(announcements.join('. '))
|
||||
}
|
||||
// Seed the baseline without announcing the items already present on mount.
|
||||
prev.current = new Map(useDownloads.getState().items.map((i) => [i.id, i.status]))
|
||||
return useDownloads.subscribe((st) => check(st.items))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.srOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 0→100% restart, SR7) or when yt-dlp can't
|
||||
report a total size, so the bar never sits frozen at 0% (L137). */}
|
||||
<ProgressBar
|
||||
className={styles.progressBar}
|
||||
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
||||
thickness="large"
|
||||
/>
|
||||
<Caption1 className={styles.stats}>
|
||||
{pct(item.progress)}
|
||||
{item.speed ? ` • ${item.speed}` : ''}
|
||||
{item.eta ? ` • ${item.eta} left` : ''}
|
||||
{item.finishing ? (
|
||||
'Finishing…'
|
||||
) : (
|
||||
<>
|
||||
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
|
||||
{item.speed ? ` • ${item.speed}` : ''}
|
||||
{item.eta ? ` • ${item.eta} left` : ''}
|
||||
</>
|
||||
)}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
@@ -206,7 +213,9 @@ export const QueueItem = memo(function QueueItem({
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Caption1 className={styles.stats}>
|
||||
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
||||
{item.scheduledFor
|
||||
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
|
||||
: 'Saved for later'}
|
||||
</Caption1>
|
||||
)}
|
||||
|
||||
|
||||
@@ -61,14 +61,16 @@ import { useErrorLog } from '../store/errorlog'
|
||||
import { Select } from './Select'
|
||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||
import { TemplateManager } from './TemplateManager'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { useErrorTextStyles } from './ui/errorText'
|
||||
import { ACCENT_OPTIONS } from '../theme'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
maxWidth: '640px'
|
||||
gap: '16px'
|
||||
},
|
||||
card: {
|
||||
display: 'flex',
|
||||
@@ -117,7 +119,7 @@ const useStyles = makeStyles({
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
flexShrink: 0,
|
||||
...shorthands.borderRadius('50%'),
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
...shorthands.border('2px', 'solid', 'transparent'),
|
||||
padding: 0,
|
||||
cursor: 'pointer'
|
||||
@@ -148,11 +150,6 @@ const useStyles = makeStyles({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
errorRowText: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -176,7 +173,7 @@ const COOKIE_SOURCE_OPTIONS = [
|
||||
|
||||
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
|
||||
value: b,
|
||||
label: b[0].toUpperCase() + b.slice(1)
|
||||
label: b.charAt(0).toUpperCase() + b.slice(1)
|
||||
}))
|
||||
|
||||
const UPDATE_CHANNEL_OPTIONS = [
|
||||
@@ -186,6 +183,8 @@ const UPDATE_CHANNEL_OPTIONS = [
|
||||
|
||||
export function SettingsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
const screen = useScreenStyles()
|
||||
|
||||
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
|
||||
// filter by toggling each card's `display` based on whether its text matches.
|
||||
@@ -252,6 +251,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
||||
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
|
||||
const [mintingPot, setMintingPot] = useState(false)
|
||||
const [potHint, setPotHint] = useState<string | null>(null)
|
||||
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||
@@ -273,22 +274,23 @@ export function SettingsView(): React.JSX.Element {
|
||||
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
|
||||
const [confirmClearLog, setConfirmClearLog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.api.cookiesStatus().then(setCookiesStatus)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getAppVersion().then(setAppVersion).catch(() => {})
|
||||
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
|
||||
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Show the current yt-dlp version without a manual click, and reflect a
|
||||
// background auto-update (which may run on launch) live in this panel.
|
||||
window.api.getYtdlpVersion().then(setVersion).catch(() => {})
|
||||
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
|
||||
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(() => {})
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||
return window.api.onYtdlpAutoUpdateStatus((s) => {
|
||||
if (s.phase === 'checking') {
|
||||
setChecking(true)
|
||||
@@ -375,17 +377,14 @@ export function SettingsView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
function copyErrorReport(): void {
|
||||
// The button is disabled when there are no entries, so `report` is always
|
||||
// non-empty here — no need for an unreachable "No errors logged." fallback (L159).
|
||||
const report = errorEntries
|
||||
.map((e) =>
|
||||
[
|
||||
new Date(e.occurredAt).toLocaleString(),
|
||||
e.title ?? e.url,
|
||||
e.url,
|
||||
e.error
|
||||
].join('\n')
|
||||
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
|
||||
)
|
||||
.join('\n\n---\n\n')
|
||||
navigator.clipboard.writeText(report || 'No errors logged.').catch(() => {})
|
||||
navigator.clipboard.writeText(report).catch(() => {})
|
||||
}
|
||||
|
||||
async function signIn(): Promise<void> {
|
||||
@@ -393,8 +392,14 @@ export function SettingsView(): React.JSX.Element {
|
||||
setLoginError(null)
|
||||
try {
|
||||
const result = await window.api.cookiesLogin(loginUrl)
|
||||
if (result.ok) setCookiesStatus(await window.api.cookiesStatus())
|
||||
else setLoginError(result.error ?? 'Sign-in failed.')
|
||||
if (result.ok && result.cookieCount === 0) {
|
||||
// Window closed without capturing anything — don't imply success (L50).
|
||||
setLoginError('No cookies were captured — did you sign in before closing the window?')
|
||||
} else if (result.ok) {
|
||||
setCookiesStatus(await window.api.cookiesStatus())
|
||||
} else {
|
||||
setLoginError(result.error ?? 'Sign-in failed.')
|
||||
}
|
||||
} catch (e) {
|
||||
setLoginError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
@@ -443,9 +448,16 @@ export function SettingsView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
|
||||
<div data-settings-search>
|
||||
<ScreenHeader
|
||||
title="Settings"
|
||||
description="Folders, formats, network, cookies, and more."
|
||||
/>
|
||||
</div>
|
||||
<div data-settings-search>
|
||||
<Input
|
||||
input={{ 'aria-label': 'Search settings' }}
|
||||
value={search}
|
||||
onChange={(_, d) => setSearch(d.value)}
|
||||
placeholder="Search settings…"
|
||||
@@ -682,7 +694,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Use aria2c downloader"
|
||||
hint="Multi-connection downloads for faster speeds on supported sites. Requires aria2c.exe in resources/bin (see the README there) — falls back to yt-dlp's downloader if it's missing."
|
||||
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
|
||||
>
|
||||
<Switch
|
||||
checked={useAria2c}
|
||||
@@ -704,13 +716,47 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="YouTube PO token (advanced)"
|
||||
hint="A manually-obtained Proof-of-Origin token for YouTube's bot check, sent via --extractor-args. Usually cookies (above) are the easier fix; automatic minting is planned."
|
||||
hint={
|
||||
potHint ??
|
||||
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={youtubePoToken}
|
||||
placeholder="(none)"
|
||||
onChange={(_, d) => update({ youtubePoToken: d.value })}
|
||||
/>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
style={{ flexGrow: 1 }}
|
||||
value={youtubePoToken}
|
||||
placeholder="(none)"
|
||||
onChange={(_, d) => {
|
||||
update({ youtubePoToken: d.value })
|
||||
setPotHint(null)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={mintingPot}
|
||||
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
|
||||
onClick={async () => {
|
||||
setMintingPot(true)
|
||||
setPotHint(null)
|
||||
try {
|
||||
const token = await window.api.mintPoToken()
|
||||
if (token) {
|
||||
setPotHint('Token saved.')
|
||||
} else {
|
||||
setPotHint(
|
||||
'Token not found — try signing into YouTube first via Cookies above.'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setPotHint('Failed to open YouTube window.')
|
||||
} finally {
|
||||
setMintingPot(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mintingPot ? 'Fetching…' : 'Fetch'}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
@@ -720,8 +766,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Cookies</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Some sites only serve full quality, age-restricted, or members-only video to a
|
||||
logged-in session. Supply cookies so yt-dlp can act like one.
|
||||
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
||||
session. Supply cookies so yt-dlp can act like one.
|
||||
</Caption1>
|
||||
|
||||
<Field label="Cookie source">
|
||||
@@ -736,7 +782,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
{cookieSource === 'browser' && (
|
||||
<Field
|
||||
label="Browser"
|
||||
hint="Reads that browser's saved cookies directly. Close it first if yt-dlp can't open a locked cookie database."
|
||||
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
|
||||
>
|
||||
<Select
|
||||
aria-label="Browser"
|
||||
@@ -779,9 +825,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>{loginError}</Caption1>
|
||||
)}
|
||||
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -872,8 +916,9 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Backup & restore</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on
|
||||
another machine. Does not include download history.
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||
machine. Does not include download history or credentials (proxy, API tokens — re-enter
|
||||
those after import).
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
@@ -888,17 +933,13 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
|
||||
)}
|
||||
{exportResult && !exportResult.ok && exportResult.error && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
|
||||
{exportResult.error}
|
||||
</Caption1>
|
||||
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
|
||||
)}
|
||||
{importResult?.ok && (
|
||||
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
|
||||
)}
|
||||
{importResult && !importResult.ok && importResult.error && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
|
||||
{importResult.error}
|
||||
</Caption1>
|
||||
<Caption1 className={errText.error}>{importResult.error}</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -920,9 +961,34 @@ export function SettingsView(): React.JSX.Element {
|
||||
>
|
||||
Copy full report
|
||||
</Button>
|
||||
<Button icon={<DeleteRegular />} onClick={clearErrorLog} disabled={errorEntries.length === 0}>
|
||||
Clear log
|
||||
</Button>
|
||||
{confirmClearLog ? (
|
||||
<>
|
||||
<Caption1>
|
||||
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
icon={<DeleteRegular />}
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
clearErrorLog()
|
||||
setConfirmClearLog(false)
|
||||
}}
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmClearLog(true)}
|
||||
disabled={errorEntries.length === 0}
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorEntries.length === 0 ? (
|
||||
@@ -937,7 +1003,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
{new Date(e.occurredAt).toLocaleString()}
|
||||
</Caption1>
|
||||
</div>
|
||||
<Caption1 className={styles.errorRowText}>{e.error}</Caption1>
|
||||
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -968,12 +1034,12 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Update access token"
|
||||
hint="Only needed if the release server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only Gitea token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional — Gitea access token"
|
||||
placeholder="Optional — access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
@@ -995,7 +1061,10 @@ export function SettingsView(): React.JSX.Element {
|
||||
{appDownloading ? 'Downloading…' : 'Update now'}
|
||||
</Button>
|
||||
{appUpd.htmlUrl && (
|
||||
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
|
||||
<Button
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => window.open(appUpd.htmlUrl, '_blank')}
|
||||
>
|
||||
View release
|
||||
</Button>
|
||||
)}
|
||||
@@ -1013,11 +1082,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Text>You'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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Textarea,
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { newId } from '../id'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
interface Line {
|
||||
@@ -20,8 +22,6 @@ interface Line {
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
gate: {
|
||||
padding: '12px 14px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
@@ -54,10 +54,6 @@ const useStyles = makeStyles({
|
||||
empty: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
||||
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
||||
@@ -65,6 +61,7 @@ function newId(): string {
|
||||
*/
|
||||
export function TerminalView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||
|
||||
const [args, setArgs] = useState('')
|
||||
@@ -101,7 +98,7 @@ export function TerminalView(): React.JSX.Element {
|
||||
function run(): void {
|
||||
const a = args.trim()
|
||||
if (!a || running) return
|
||||
const id = newId()
|
||||
const id = newId('t')
|
||||
runId.current = id
|
||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
||||
setRunning(true)
|
||||
@@ -126,17 +123,25 @@ export function TerminalView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const lineClass = (kind: LineKind): string | undefined =>
|
||||
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
|
||||
kind === 'cmd'
|
||||
? styles.cmd
|
||||
: kind === 'stderr'
|
||||
? styles.stderr
|
||||
: kind === 'sys'
|
||||
? styles.sys
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Terminal</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||
</Caption1>
|
||||
</div>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader
|
||||
title="Terminal"
|
||||
description={
|
||||
<>
|
||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!customCommandEnabled && (
|
||||
<Body1 className={styles.gate}>
|
||||
@@ -149,6 +154,7 @@ export function TerminalView(): React.JSX.Element {
|
||||
<div className={styles.inputCol}>
|
||||
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||
<Textarea
|
||||
textarea={{ 'aria-label': 'yt-dlp arguments' }}
|
||||
value={args}
|
||||
onChange={(_, d) => setArgs(d.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -42,28 +42,35 @@ export function VirtualList<T>({
|
||||
estimateSize,
|
||||
overscan,
|
||||
gap,
|
||||
getItemKey: (index) => getKey(items[index], index)
|
||||
getItemKey: (index) => {
|
||||
const it = items[index]
|
||||
return it !== undefined ? getKey(it, index) : index
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
{virtualizer.getVirtualItems().map((vrow) => (
|
||||
<div
|
||||
key={vrow.key}
|
||||
data-index={vrow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vrow.start}px)`
|
||||
}}
|
||||
>
|
||||
{renderItem(items[vrow.index], vrow.index)}
|
||||
</div>
|
||||
))}
|
||||
{virtualizer.getVirtualItems().map((vrow) => {
|
||||
const item = items[vrow.index]
|
||||
if (item === undefined) return null
|
||||
return (
|
||||
<div
|
||||
key={vrow.key}
|
||||
data-index={vrow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vrow.start}px)`
|
||||
}}
|
||||
>
|
||||
{renderItem(item, vrow.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './focusRing'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
btn: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
},
|
||||
':disabled': {
|
||||
cursor: 'not-allowed',
|
||||
color: tokens.colorNeutralForegroundDisabled,
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
},
|
||||
md: { width: '32px', height: '32px', fontSize: '18px' },
|
||||
sm: { width: '28px', height: '28px', fontSize: '16px' }
|
||||
})
|
||||
|
||||
type IconButtonProps = {
|
||||
icon: React.JSX.Element
|
||||
size?: 'sm' | 'md'
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
/**
|
||||
* The recurring icon-only ghost button (UI15) — one implementation for the
|
||||
* sidebar's collapse/theme toggles and the playlist row's video/audio switch,
|
||||
* which were separate hand-rolled buttons with the same look. Carries the shared
|
||||
* focus ring (UI29). Extra props (onClick, aria-label, aria-pressed, disabled, …)
|
||||
* pass straight through to the underlying `<button>`.
|
||||
*/
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{ icon, size = 'md', className, type = 'button', ...rest },
|
||||
ref
|
||||
) {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={mergeClasses(styles.btn, styles[size], focus.focusRing, className)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Single id generator for queue items, terminal runs, and command templates.
|
||||
// Replaces three near-identical `newId()` copies that had divergent fallback
|
||||
// prefixes and could collide within a millisecond (M7 / L33 / L70).
|
||||
//
|
||||
// `crypto.randomUUID` is the real path in Electron/Node; the counter-suffixed
|
||||
// fallback is effectively unreachable but kept so the function is total in any
|
||||
// host, and the monotonic counter makes two ids minted in the same millisecond
|
||||
// distinct (the old `Date.now()`-only fallbacks could collide).
|
||||
let counter = 0
|
||||
|
||||
export function newId(prefix = 'id'): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `${prefix}-${Date.now()}-${++counter}`
|
||||
}
|
||||
+55
-12
@@ -3,6 +3,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
|
||||
import App from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
||||
// In the standalone UI preview (browser, no Electron preload) window.api isn't
|
||||
// injected. Stub the full surface so the UI renders and stays interactive during
|
||||
@@ -18,7 +19,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
theme: 'system',
|
||||
accentColor: 'teal',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
@@ -75,7 +76,10 @@ if (import.meta.env.DEV && !window.api) {
|
||||
runAppUpdate: async () => ({ ok: true }),
|
||||
onAppUpdateProgress: () => () => {},
|
||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
|
||||
getFfmpegVersions: async () => ({
|
||||
ffmpeg: '7.1-full_build (UI preview mock)',
|
||||
ffprobe: '7.1-full_build (UI preview mock)'
|
||||
}),
|
||||
probe: async (url: string) => {
|
||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
||||
@@ -108,10 +112,38 @@ if (import.meta.env.DEV && !window.api) {
|
||||
thumbnail: undefined,
|
||||
formats: [
|
||||
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
|
||||
{ id: '299', label: '1080p60 · mp4 · 248 MB', height: 1080, ext: 'mp4', filesizeLabel: '248 MB', hasAudio: false },
|
||||
{ id: '136', label: '720p · mp4 · 124 MB', height: 720, ext: 'mp4', filesizeLabel: '124 MB', hasAudio: false },
|
||||
{ id: '135', label: '480p · mp4 · 72 MB', height: 480, ext: 'mp4', filesizeLabel: '72 MB', hasAudio: false },
|
||||
{ id: '134', label: '360p · mp4 · 38 MB', height: 360, ext: 'mp4', filesizeLabel: '38 MB', hasAudio: false }
|
||||
{
|
||||
id: '299',
|
||||
label: '1080p60 · mp4 · 248 MB',
|
||||
height: 1080,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '248 MB',
|
||||
hasAudio: false
|
||||
},
|
||||
{
|
||||
id: '136',
|
||||
label: '720p · mp4 · 124 MB',
|
||||
height: 720,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '124 MB',
|
||||
hasAudio: false
|
||||
},
|
||||
{
|
||||
id: '135',
|
||||
label: '480p · mp4 · 72 MB',
|
||||
height: 480,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '72 MB',
|
||||
hasAudio: false
|
||||
},
|
||||
{
|
||||
id: '134',
|
||||
label: '360p · mp4 · 38 MB',
|
||||
height: 360,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '38 MB',
|
||||
hasAudio: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -119,7 +151,6 @@ if (import.meta.env.DEV && !window.api) {
|
||||
startDownload: async () => ({ ok: true }),
|
||||
cancelDownload: async () => {},
|
||||
pauseDownload: async () => {},
|
||||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||||
chooseFolder: async () => null,
|
||||
openPath: async () => '',
|
||||
showInFolder: async () => {},
|
||||
@@ -162,16 +193,25 @@ if (import.meta.env.DEV && !window.api) {
|
||||
},
|
||||
updateYtdlp: async (channel) => {
|
||||
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
|
||||
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
|
||||
return {
|
||||
ok: true,
|
||||
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
|
||||
}
|
||||
},
|
||||
// No background updater in the browser preview — hand back an inert unsubscribe.
|
||||
onYtdlpAutoUpdateStatus: () => () => {},
|
||||
listErrorLog: async () => [],
|
||||
clearErrorLog: async () => [],
|
||||
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
|
||||
exportBackup: async () => ({
|
||||
ok: true,
|
||||
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
|
||||
}),
|
||||
importBackup: async () => ({ ok: true }),
|
||||
onDownloadEvent: () => () => {},
|
||||
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
|
||||
getSystemTheme: async () => ({
|
||||
shouldUseDarkColors: false,
|
||||
shouldUseHighContrastColors: false
|
||||
}),
|
||||
onSystemThemeUpdate: () => () => {},
|
||||
openHighContrastSettings: async () => {},
|
||||
onExternalUrl: () => () => {},
|
||||
@@ -229,12 +269,15 @@ if (import.meta.env.DEV && !window.api) {
|
||||
runTerminal: async () => ({ ok: true }),
|
||||
cancelTerminal: async () => {},
|
||||
onTerminalOutput: () => () => {},
|
||||
setTaskbarProgress: () => {}
|
||||
setTaskbarProgress: () => {},
|
||||
mintPoToken: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 0→100% 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)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -12,7 +13,9 @@ const FALLBACK: Settings = {
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
// Mirror main's DEFAULTS (SR1): follow the OS theme until real settings load,
|
||||
// so there's no white-on-first-paint flash for a dark-mode user.
|
||||
theme: 'system',
|
||||
accentColor: 'teal',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
@@ -26,12 +29,12 @@ const FALLBACK: Settings = {
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false,
|
||||
autoUpdateYtdlp: true,
|
||||
ytdlpChannel: 'nightly',
|
||||
ytdlpChannel: 'stable',
|
||||
ytdlpLastUpdateCheck: 0,
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
autoDownloadNew: false,
|
||||
// True in preview so design work isn't blocked behind the welcome screen;
|
||||
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
||||
hasCompletedOnboarding: PREVIEW,
|
||||
@@ -56,14 +59,31 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
|
||||
update: (partial) => {
|
||||
set(partial) // optimistic local update
|
||||
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||
if (PREVIEW) return
|
||||
// M34: reconcile with main's authoritative, validated result — for exactly the
|
||||
// keys we changed — so a value main clamps, sanitizes, or rejects (e.g. an
|
||||
// unsafe filenameTemplate, an out-of-range maxConcurrent, a malformed
|
||||
// rateLimit) stops showing as accepted in the UI until restart.
|
||||
window.api
|
||||
.setSettings(partial)
|
||||
.then((saved) => {
|
||||
const reconciled = Object.fromEntries(
|
||||
(Object.keys(partial) as (keyof Settings)[]).map((k) => [k, saved[k]])
|
||||
) as Partial<Settings>
|
||||
set(reconciled)
|
||||
})
|
||||
.catch(logError('setSettings'))
|
||||
},
|
||||
|
||||
chooseDir: (target) => {
|
||||
if (PREVIEW) return
|
||||
window.api.chooseFolder().then((dir) => {
|
||||
if (dir) get().update({ [target]: dir })
|
||||
})
|
||||
// Seed the OS picker with the folder this target currently points at (W5).
|
||||
window.api
|
||||
.chooseFolder(get()[target])
|
||||
.then((dir) => {
|
||||
if (dir) get().update({ [target]: dir })
|
||||
})
|
||||
.catch(logError('chooseFolder'))
|
||||
},
|
||||
|
||||
clearDir: (target) => get().update({ [target]: '' })
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
@@ -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 0→100% 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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
})
|
||||
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,262 +141,260 @@ 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)',
|
||||
() => {
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'crop',
|
||||
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // "Me at the zoo", 19s
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: true
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[crop] argv:', JSON.stringify(argv))
|
||||
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
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: true
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[crop] argv:', JSON.stringify(argv))
|
||||
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[crop] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
expect(statSync(res.filePath!).size).toBeGreaterThan(1000)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[crop] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
expect(statSync(res.filePath!).size).toBeGreaterThan(1000)
|
||||
|
||||
const info = ffinfo(res.filePath!)
|
||||
const dims = parseCoverDims(info)
|
||||
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
|
||||
)
|
||||
const info = ffinfo(res.filePath!)
|
||||
const dims = parseCoverDims(info)
|
||||
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)
|
||||
|
||||
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)
|
||||
expect(source, 'should read source duration').toBeGreaterThan(60)
|
||||
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)
|
||||
expect(source, 'should read source duration').toBeGreaterThan(60)
|
||||
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'sb',
|
||||
url,
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false,
|
||||
sponsorBlock: true,
|
||||
sponsorBlockMode: 'remove' as const,
|
||||
sponsorBlockCategories: ['music_offtopic' as const]
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[sb] argv:', JSON.stringify(argv))
|
||||
const opts: StartDownloadOptions = {
|
||||
id: 'sb',
|
||||
url,
|
||||
kind: 'audio',
|
||||
quality: 'Best (MP3)'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'mp3' as const,
|
||||
embedThumbnail: false,
|
||||
sponsorBlock: true,
|
||||
sponsorBlockMode: 'remove' as const,
|
||||
sponsorBlockCategories: ['music_offtopic' as const]
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[sb] argv:', JSON.stringify(argv))
|
||||
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[sb] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath).toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[sb] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath).toBeTruthy()
|
||||
expect(existsSync(res.filePath!)).toBe(true)
|
||||
|
||||
const outDur = parseDurationSec(ffinfo(res.filePath!))
|
||||
console.log('[sb] output duration:', outDur, 'removed ≈', (source ?? 0) - (outDur ?? 0), 's')
|
||||
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
|
||||
)
|
||||
const outDur = parseDurationSec(ffinfo(res.filePath!))
|
||||
console.log('[sb] output duration:', outDur, 'removed ≈', (source ?? 0) - (outDur ?? 0), 's')
|
||||
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)
|
||||
|
||||
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',
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'opus' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.opus'), 'output ext should be .opus').toBe(true)
|
||||
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',
|
||||
kind: 'audio',
|
||||
quality: 'Best'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
audioFormat: 'opus' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[opus] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
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))
|
||||
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
|
||||
)
|
||||
const audio = streamsOfType(res.filePath!, 'audio')
|
||||
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)
|
||||
|
||||
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
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mkv' as const,
|
||||
preferredVideoCodec: 'vp9' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[mkv-vp9] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[mkv-vp9] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.mkv'), 'output ext should be .mkv').toBe(true)
|
||||
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
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mkv' as const,
|
||||
preferredVideoCodec: 'vp9' as const,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[mkv-vp9] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[mkv-vp9] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
expect(res.filePath!.toLowerCase().endsWith('.mkv'), 'output ext should be .mkv').toBe(true)
|
||||
|
||||
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))
|
||||
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
|
||||
)
|
||||
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)
|
||||
)
|
||||
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)
|
||||
|
||||
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
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mp4' as const,
|
||||
embedSubtitles: true,
|
||||
autoSubtitles: true,
|
||||
subtitleLanguages: 'en',
|
||||
embedChapters: true,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[subs-ch] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
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
|
||||
kind: 'video',
|
||||
quality: '360p'
|
||||
}
|
||||
const options = {
|
||||
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||
videoContainer: 'mp4' as const,
|
||||
embedSubtitles: true,
|
||||
autoSubtitles: true,
|
||||
subtitleLanguages: 'en',
|
||||
embedChapters: true,
|
||||
embedThumbnail: false
|
||||
}
|
||||
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
|
||||
console.log('[subs-ch] argv:', JSON.stringify(argv))
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[subs-ch] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
|
||||
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)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
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 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)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
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 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)
|
||||
const res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[restrict] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
|
||||
const base = baseName(res.filePath!)
|
||||
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
|
||||
)
|
||||
const base = baseName(res.filePath!)
|
||||
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)
|
||||
|
||||
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(
|
||||
{ id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' },
|
||||
join(outDir, 'arch-%(id)s.%(ext)s'),
|
||||
{ ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false },
|
||||
BIN_DIR,
|
||||
{ ...NO_ACCESS, downloadArchivePath: 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(
|
||||
{ id, url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', kind: 'audio', quality: 'Best' },
|
||||
join(outDir, 'arch-%(id)s.%(ext)s'),
|
||||
{ ...DEFAULT_DOWNLOAD_OPTIONS, audioFormat: 'mp3' as const, embedThumbnail: false },
|
||||
BIN_DIR,
|
||||
{ ...NO_ACCESS, downloadArchivePath: archive }
|
||||
)
|
||||
|
||||
const first = runYtdlp(mk('a1'))
|
||||
if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr)
|
||||
expect(first.status, 'first run should download').toBe(0)
|
||||
expect(first.filePath, 'first run should produce a file').toBeTruthy()
|
||||
expect(existsSync(archive), 'archive file should be written').toBe(true)
|
||||
const first = runYtdlp(mk('a1'))
|
||||
if (first.status !== 0) console.error('[archive] first stderr:\n' + first.stderr)
|
||||
expect(first.status, 'first run should download').toBe(0)
|
||||
expect(first.filePath, 'first run should produce a file').toBeTruthy()
|
||||
expect(existsSync(archive), 'archive file should be written').toBe(true)
|
||||
|
||||
const second = runYtdlp(mk('a2'))
|
||||
expect(second.status, 'second run should still exit 0').toBe(0)
|
||||
// The id is in the archive, so the second run downloads nothing — and thus
|
||||
// prints no after_move `path|` line. (yt-dlp's "has already been recorded"
|
||||
// notice is itself silenced because buildArgs' --print implies --quiet, so
|
||||
// we assert on the archive contents + the absence of a fresh download.)
|
||||
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
|
||||
)
|
||||
const second = runYtdlp(mk('a2'))
|
||||
expect(second.status, 'second run should still exit 0').toBe(0)
|
||||
// The id is in the archive, so the second run downloads nothing — and thus
|
||||
// prints no after_move `path|` line. (yt-dlp's "has already been recorded"
|
||||
// notice is itself silenced because buildArgs' --print implies --quiet, so
|
||||
// we assert on the archive contents + the absence of a fresh download.)
|
||||
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)
|
||||
|
||||
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 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 res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
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 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 res = runYtdlp(argv)
|
||||
if (res.status !== 0) console.error('[extra] stderr:\n' + res.stderr)
|
||||
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node", "node"],
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@renderer/*": ["./src/renderer/src/*"],
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
|
||||
+5
-3
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user