22 Commits

Author SHA1 Message Date
debont80 d4fcd19e17 chore: bump version to 0.5.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:38:24 -04:00
debont80 24c4c807a3 feat: Phase P reliability (YouTube extractor-args plumbing, signing + smoke-test docs)
- YouTube reliability: Settings.youtubePlayerClient + youtubePoToken ->
  one --extractor-args "youtube:player_client=...;po_token=..." group
  (accessArgs, unit-tested); advanced fields in Settings -> Network.
  Automatic WebView PO-token minting deferred (documented).
- docs/SMOKE-TEST.md: release-gate checklist for all shipped-but-untested
  OS wiring (cookies window, protocol/Send-to, scheduled sync, aria2c, proxy,
  tray/taskbar/pause-resume/terminal) — needs a human to run.
- docs/SIGNING.md: code-signing guide (CSC_LINK/CSC_KEY_PASSWORD, OV/EV,
  CI, HSM hook, verification). Build is signing-ready; needs a cert.
- i18n remains deferred (not faked).

typecheck + test (195) + build all clean. Roadmap: Phase P code shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:25:57 -04:00
debont80 20ee913394 feat: Phase O Windows-native integration + settings search
- Taskbar progress bar: renderer pushes summarizeQueue aggregate over a new
  taskbar:progress channel (App store subscription); setProgressBar normal/error
- System tray (src/main/tray.ts) + minimize-to-tray (Settings.minimizeToTray);
  close hides to tray, isQuitting guards real exits; icon via getAppIconPath()
  (build/icon.ico added to extraResources)
- Jump list: app.setUserTasks "Open AeroFetch" (thumbnail toolbar deferred)
- Settings search: filters the ~11 SettingsView cards live by text match

Overlay badge deferred (needs a drawn NativeImage). typecheck + test (192) +
build all clean. Roadmap: Phase O COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:20:28 -04:00
debont80 da37690b42 feat: Phase N power-user surface (terminal, palette, per-item, sort, url-templates)
- Built-in yt-dlp terminal: src/main/terminal.ts streams raw yt-dlp runs
  (binary fixed, gated on customCommandEnabled), new TerminalView + sidebar tab
- Command palette (Ctrl/Cmd+K), portal-free CommandPalette.tsx wired in App
- Per-playlist-item editing: per-row video/audio toggle + All video/All audio
  batch; addPlaylist enqueues via addMany with per-item kind
- Weighted format sorting: DownloadOptions.formatSort -> raw -S (overrides codec)
- URL-regex template auto-matching: CommandTemplate.urlPattern + selectExtraArgs
  matchesUrl, threaded through resolveExtraArgs; field in TemplateManager

typecheck + test (192) + electron-vite build all clean. Roadmap: Phase N COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:10:22 -04:00
debont80 9ac11ceb6c feat: queue UX (Phase M) + media editing (split-chapters, trim)
Phase L (media editing):
- Split by chapters (--split-chapters) as a DownloadOptions toggle
- Trim/cut by time range: parseTrimSections -> --download-sections "*A-B"
  + --force-keyframes-at-cuts (deduped vs SponsorBlock), per-download Trim panel

Phase M (queue & daily-use UX), all 7:
- Pause/resume: main-side killTree + paused flag (silent close), download:pause
  IPC, store pause/resume + paused status, QueueItem controls
- Reorder via "Download next" (prioritize)
- Aggregate progress strip (pure summarizeQueue) + combined speed/ETA
- Drag-and-drop links / .url files onto the download card
- Retry all failed
- Duplicate detection (sameVideo) with "Download anyway" guard
- Per-download scheduling (datetime-local) + save-for-later (saved status,
  15s promotion ticker); session-only (queue not persisted)

New pure modules unit-tested (queueStats); buildArgs trim/split tested.
typecheck + test green (178 passed). Roadmap updated: Phase M COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:55:43 -04:00
debont80 43a62dc4a2 docs: add post-parity roadmap (YTDLnis features, native polish, reliability)
Fold Phases L-P into ROADMAP.md: media editing (trim/cut, split-chapters,
metadata editing), queue/daily-use UX, power-user surface, Windows-native
integration, and reliability/trust. Cross-link from ROADMAP-PINCHFLAT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:38:17 -04:00
debont80 28237bdaa2 feat: collapsible playlist groups + per-playlist download in Library
Library channel cards now collapse their playlist groups by default
(single-group sources auto-expand), and each group header has a
Download button that queues just that playlist's pending videos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:13:08 -04:00
debont80 8e161fe9ce feat: Library bulk download + list virtualization for large channels
Add a source-wide "Select all" across all playlist groups, and make one
click queue every indexed video (the previous 100-per-click cap is gone).
Bulk download skips items that are already downloaded or in flight, so it
can't enqueue duplicates, and it includes failed/canceled ones so it also
retries them.

Make the UI hold up at channel scale (1000s of videos): virtualize both
the Downloads queue and the Library item list with @tanstack/react-virtual
so only on-screen rows hit the DOM, memoize the queue row so they don't all
reconcile on every progress tick, and batch the enqueue so queuing a whole
channel stays O(n) instead of O(n^2).

- VirtualList: reusable windowed list that owns its own scroll container
- DownloadsView: virtualized, flex-filled queue
- LibraryView: groups flattened to rows; virtualized panel above 100 rows,
  inline below so small sources are unchanged
- QueueItem: React.memo
- downloads store: addMany (one state update + one pump); sources:
  enqueue the whole selection via addMany, no cap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:27:52 -04:00
debont80 4854fcc947 feat: self-managing yt-dlp + ffmpeg version display
yt-dlp is now a managed, auto-updating binary rather than a static bundled
one. On launch AeroFetch seeds a writable copy under userData from the
bundled seed, self-heals it if it goes missing, and — throttled to once a
day — self-updates it to the configured channel (default: nightly). This
keeps the binary current so YouTube changes don't silently cause 403s, and
an app reinstall (or portable re-extraction) can no longer roll it back.
Settings shows an auto-update toggle, the live version, last-checked time,
and the channel selector.

Also surface the bundled ffmpeg/ffprobe versions in Settings (display only:
they have no self-update path and, unlike yt-dlp, don't break as sites
change, so there is no auto-update for them).

- binaries: split the read-only bundled seed from the managed userData copy
- ytdlp: ensureManagedYtdlp (seed + self-heal), startup auto-update runner
- ytdlpPolicy: pure once-a-day throttle decision (unit-tested)
- ffmpeg: parse ffmpeg/ffprobe -version for the Settings panel
- settings/ipc/preload/renderer: new settings, IPC channels, types, and UI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 07:27:43 -04:00
debont80 fa699a803d chore: bump version to 0.4.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:24:15 -04:00
debont80 97e725c774 Merge pull request 'In-app updater (check releases, show changelog, download + install)' (#4) from feat/app-updater into main
Reviewed-on: #4
2026-06-25 05:45:37 -04:00
debont80 fa92383ef4 Verify update installer SHA-256; harden download teardown
Integrity + defence-in-depth for the in-app updater:

- Require and verify a SHA-256 checksum before running an installer. Each
  release must attach `<installer>.sha256`; downloadAppUpdate fetches it from
  the host-pinned origin (deriving the URL itself, not trusting the renderer),
  hashes the stream as it writes, and refuses to run on mismatch/missing/
  unparseable. Fails closed. NB: same-host hash is integrity, not protection
  against a fully compromised host — only Authenticode signing covers that.
- fetchTrustedText helper GETs the checksum with the same per-hop host
  re-validation as the installer download, plus a size cap and timeout.
- finish() is now the single teardown point and aborts the request on failure,
  so a write error can't leave Electron pulling bytes into a dead stream;
  removed three now-redundant explicit abort() calls.
- Clear the idle/stall timer once the body is fully received.
- Export isTrustedDownloadUrl/compareVersions, extract extractSha256, and add
  14 unit tests (host pin incl. userinfo spoof + wrong port, checksum parsing
  incl. sha512 non-slice, prerelease never outranking its release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 05:38:02 -04:00
debont80 7134a3d634 Harden app updater: per-hop redirect pinning, truncation + path-guard fixes
Review follow-ups on the in-app updater:

- Download via Electron net.request with redirect:'manual', re-validating the
  host on EVERY hop. fetch() followed redirects automatically and undici hides
  the Location header, so the trusted-host pin only covered the first URL — a
  302 from the trusted host could bounce the download to an arbitrary server.
- Reject truncated downloads (received !== Content-Length) and an 'aborted'
  mid-stream so a partial installer is never launched; clean up the temp file
  on any failure.
- runAppUpdate: compare the parent dir by equality instead of startsWith(),
  which a sibling like `<temp>_evil\x.exe` defeated.
- checkForAppUpdate: 15s AbortController timeout (mirrors the yt-dlp calls) and
  pin htmlUrl to the trusted host before handing it to the OS browser.
- parseVersion: ignore -prerelease/+build suffixes so a prerelease never sorts
  above its final release.
- SettingsView: funnel a failed check into the single error slot so two error
  messages can't render at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 05:06:01 -04:00
wayne 981d2dd34d Add in-app updater: check Gitea releases, show changelog, download + run installer
A new Settings → "Software update" card lets users update AeroFetch from inside
the app:

- src/main/updater.ts — queries the configured Gitea repo's latest release over
  the public REST API, compares the tag to app.getVersion(), and (on request)
  streams the installer asset to the OS temp dir with progress events, then
  launches it and quits so it can replace the app.
- Security: only the trusted update host over HTTPS is ever downloaded from, and
  only a freshly-downloaded .exe inside our temp dir is ever executed — the
  download URL from the release JSON can't redirect us elsewhere. No token is
  ever shipped; the source repo must be anonymously readable (public).
- IPC: app:update-check / -download / -run + an -progress push channel, exposed
  via preload (checkForAppUpdate / downloadAppUpdate / runAppUpdate /
  onAppUpdateProgress).
- UI: "Check for updates" → shows the new version and its release notes, then
  "Update now" (with a download progress bar) or "View release". Up-to-date and
  error states handled. Preview mock added so the flow is exercisable in `npm run ui`.

Note: the update source repo/host are constants at the top of updater.ts
(currently debont80/AeroFetch). Downloads work once the release repo is public
and the Gitea instance allows anonymous API + asset access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:38:35 -04:00
debont80 08b9ed9e7a chore: bump version to 0.4.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:01:51 -04:00
debont80 5e3512f366 feat: show real media thumbnails with theme-aware fallback
Add MediaThumb component and thumb.ts helper that resolve a preview image
from a probed thumbnail, a known videoId, or a derivable YouTube id
(mqdefault.jpg), falling back to a kind icon on a neutral tint when no
image is available or it fails to load. Wire it into the queue, history,
and library rows.

Also fix theme resolution: introduce useResolvedDark() as the single
source of truth for light/dark so 'system' is resolved against the live
OS signal, fixing thumbnails/colors staying light under Auto + dark OS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:01:38 -04:00
debont80 e8ab8b9c73 Merge feat/documents-folders-and-ui: Documents folders, collapsible sidebar, theme switch
# Conflicts:
#	src/main/download.ts
#	src/renderer/src/components/DownloadBar.tsx
2026-06-24 11:26:52 -04:00
debont80 76098c6928 Merge pull request 'security: harden command exec, IPC, deep-link, cookies, and persistence' (#2) from security/audit-hardening into main
Reviewed-on: #2
2026-06-24 08:06:55 -04:00
debont80 3536626a8a security: harden command exec, IPC, deep-link, cookies, and persistence
Seven-tier security audit of the main process, each finding fixed with a
regression test. Typecheck (node + web) clean; unit tests 106 -> 140.

- Tier 1 (command exec/argv): allowlist the yt-dlp --update-to channel
  (blocks arbitrary-binary-install RCE); gate per-download extraArgs behind
  the customCommandEnabled consent flag in main (blocks --exec RCE); resolve
  taskkill/schtasks by absolute System32 path; validate per-download
  outputDir; normalize the URL in assertHttpUrl and use it at every spawn.
- Tier 2 (input validation): fix isSafeFilenameTemplate drive-relative
  ('C:foo') and Windows dotted-'..' traversal bypasses; percent-encode the
  untrusted id in entryUrl; catch reserved device names with extensions in
  sanitizeDirSegment.
- Tier 3 (fs/backup): drop malformed template rows in importBackup;
  normalize deep-link URLs via assertHttpUrl.
- Tier 4 (cookies): confine the sign-in window's navigations/popups to web
  URLs (recursively) and deny all web permissions on its session.
- Tier 5 (deep-link/argv): bound the .url file read to 64 KB; match the
  aerofetch:// scheme case-insensitively.
- Tier 6 (Electron window): deny camera/mic/geolocation/USB/HID/serial/
  Bluetooth permissions on the app window.
- Tier 7 (network/persistence): restrict the watched-source RSS fetch to
  youtube.com feed URLs (SSRF guard); complete isValidSource validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:04:19 -04:00
wayne 2718624828 Show app version in sidebar, make the sidebar collapsible, and add a Light/Dark/Auto theme switch
- Expose the AeroFetch version over IPC (app:version → app.getVersion); the
  sidebar brand now shows "v<version>" beneath the name.
- Sidebar can collapse to a 60px icon-only rail via a toggle button; nav items
  and the theme control fall back to icon + tooltip when collapsed. The state is
  persisted in localStorage so it survives restarts. Hint gains left/right
  placements for the collapsed-rail tooltips.
- Replace the binary dark-mode Switch with an explicit 3-way Light / Dark / Auto
  segmented control (Auto follows the OS). Collapsed, it becomes a single button
  that cycles the three modes.
- Bump version to 0.3.2.

Verified in the browser UI preview: version label, collapse/expand, dark mode,
and the theme switch all render correctly with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:43:44 -04:00
wayne 37687f9870 Separate per-kind video/audio folders, defaulting to Documents\Video and Documents\Audio
Existing installs had outputDir persisted to the Downloads folder (auto-filled by
earlier versions), so the v0.3.0 per-kind routing never triggered — downloads kept
going to Downloads. Replace the single outputDir setting with independent videoDir
and audioDir settings:

- Settings model: drop Settings.outputDir; add videoDir + audioDir (both blank by
  default). A blank value routes that kind into Documents\Video / Documents\Audio;
  a chosen folder overrides it. The stale outputDir key in old settings files is
  simply ignored, so existing users now get the Documents defaults.
- buildCommand routes by kind: per-download override → per-kind folder → default.
- The renderer no longer sends a global outputDir, so main always routes by kind.
- Settings: the single "Download folder" field becomes separate "Video folder" and
  "Audio folder" pickers, each with a Browse + Reset and a Documents\… placeholder.
  Store gains chooseDir(target) / clearDir(target).
- Onboarding: replace the folder picker with a short note about the two default
  folders (changeable in Settings).
- Bump version to 0.3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:11:15 -04:00
wayne a2763f10b4 Route downloads into Documents\Video / Documents\Audio and simplify the home page
- Create Documents\Video and Documents\Audio on startup (ensureMediaDirs), and
  route each download into them by kind when no explicit output folder is set.
  An empty outputDir now means "sort by type"; a chosen folder still overrides
  for both kinds.
- Settings/Onboarding "Download folder" gains a placeholder + hint describing
  the blank = route-by-type behaviour.
- Trim the home download bar to the essentials: URL box, Search and Paste
  buttons, format/quality, and the Download button. Removed the private-mode
  toggle, per-download Options panel, custom-command panel, command preview, and
  the saving-to-folder line.
- Bump version to 0.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:17:16 -04:00
61 changed files with 4916 additions and 713 deletions
+20 -17
View File
@@ -16,6 +16,9 @@ Sources: [Pinchflat GitHub](https://github.com/kieraneglin/pinchflat) ·
[Pinchflat architecture (DeepWiki)](https://deepwiki.com/kieraneglin/pinchflat) ·
[Pinchflat FAQ — indexing](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions).
> **See also:** [ROADMAP.md](ROADMAP.md) → **Post-parity** — YTDLnis-derived editing features,
> Windows-native polish, and reliability work, Phases L onward.
---
## Implementation status — Phases FK shipped (2026-06-23)
@@ -88,14 +91,14 @@ user indexes many large channels and the JSON files get unwieldy (noted as a Pha
Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
yet. This is the prerequisite for every later phase.
- [ ] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
- [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
playlist's video list on demand rather than all up front.
- [ ] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
- [x] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
```ts
interface Source {
@@ -119,11 +122,11 @@ yet. This is the prerequisite for every later phase.
filePath?: string
}
```
- [ ] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
no real playlist.
- [ ] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
- [x] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
@@ -131,13 +134,13 @@ yet. This is the prerequisite for every later phase.
Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`.
- [ ] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
- [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
joined subpath) so each `MediaItem` downloads into
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
- [ ] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
- [x] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
not the directory segments AeroFetch constructs from channel/playlist names.
@@ -155,31 +158,31 @@ Turn a `MediaItem` into a real per-item download that lands in `Channel / Playli
The new surface where channels live. Everything below feeds the **existing** download queue.
- [ ] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
- [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
Downloads / History / Settings. Lists added Sources.
- [ ] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
- [x] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
Per-playlist select-all, live counts.
- [ ] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
- [x] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never
holds the entire channel at once — the live queue stays small while the Library view is the
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
records to history exactly as today.
- [ ] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
- [x] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
`store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data
so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`).
- [ ] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
- [x] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
## Phase I — Incremental sync & dedup ✅ COMPLETE
Re-running a channel should grab only what's new — the everyday media-manager loop.
- [ ] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
- [x] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
persisted `MediaItem`s and marks only the new ones; existing records (and their
`downloaded` state) are preserved. "X new since last sync" badge.
- [ ] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
- [x] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
can't re-download.
@@ -192,14 +195,14 @@ Pinchflat's headline feature: a Source you *watch*, that downloads new uploads o
AeroFetch already has the Windows pieces for this elsewhere in this workspace (Task Scheduler +
single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns).
- [ ] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
- [ ] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
- [x] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
- [x] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
(`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new
uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15
videos, so RSS is for *staying current*, not initial indexing (which stays a full
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
playlists / initial scans").
- [ ] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
- [x] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
enqueues new items, wired to **Windows Task Scheduler** (reuse the
single-instance-lock already added in Phase E so a scheduled run hands off to a running
window instead of double-launching). Notify on new downloads via the existing
@@ -210,7 +213,7 @@ single-instance Mutex + a headless run mode — see the DashMail/Weather Radar p
Make AeroFetch's output drop-in for Jellyfin/Plex/Kodi the way Pinchflat does — relevant
because organized channel folders are exactly what those servers ingest.
- [ ] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
- [x] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through
Phase C custom-command args; a checkbox group in the output settings makes it first-class.
+209 -5
View File
@@ -13,6 +13,9 @@ Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
> AeroFetch from a one-shot downloader into a [Pinchflat](https://github.com/kieraneglin/pinchflat)-style
> media manager (index entire channels → organize into `Channel / Playlist / Title` folders →
> keep in sync), reusing the existing queue/history/options rather than replacing them.
>
> **Post-parity work** (YTDLnis-derived editing features + Windows-native/UX polish + reliability)
> is tracked at the end of this document under **Post-parity** (Phases L onward).
---
@@ -163,7 +166,7 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
**Copy full report** and **Clear log**.
## Phase E — Theming & platform polish
## Phase E — Theming & platform polish ✅ COMPLETE
Some items are Android-specific in Seal and adapted to Windows here.
@@ -184,10 +187,6 @@ Some items are Android-specific in Seal and adapted to Windows here.
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
toggle's break-out-of-system behavior all checked visually.
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
since shipping ~40 real translations isn't something to fake; doing this properly means
wiring up an i18n library + extracting every string now, with translation content
arriving incrementally from contributors.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target:
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
@@ -234,3 +233,208 @@ Some items are Android-specific in Seal and adapted to Windows here.
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
for this app's NSIS/portable distribution).
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
---
# Post-parity — editing, native polish & reliability
With both parity tracks complete — Seal (Phases AE above) and the
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases FK ✅) — this final track
collects what's left, from two sources:
1. A **feature study of [YTDLnis](https://github.com/deniscerri/ytdlnis)** (the other major
Android yt-dlp frontend), keeping only the features AeroFetch doesn't already have.
2. A **Windows-native + daily-use UX gap analysis** of AeroFetch itself (things neither Seal
nor Pinchflat cover, but that a desktop app should do).
Nothing here is built yet — every box is unchecked. Phases continue the existing lettering
(AE Seal, FK Pinchflat) from **L onward**, and each item carries the yt-dlp flag and the
AeroFetch file it touches so it can be driven straight from the existing `buildArgs` /
`DownloadOptions` / queue plumbing rather than new infrastructure.
Sources: [YTDLnis GitHub](https://github.com/deniscerri/ytdlnis) ·
[YTDLnis docs](https://ytdlnis.org/) · [YTDLnis changelogs](https://ytdlnis.org/changelogs/) ·
[F-Droid listing](https://f-droid.org/packages/com.deniscerri.ytdl/).
### Already at parity with YTDLnis (do **not** rebuild)
Format/quality/container picking · SponsorBlock (remove/mark + embed-as-chapters) · embed
subtitles/metadata/chapters · cookies + login · custom command templates · backup/restore ·
**Observe Sources** (= our Phase J watched sources + RSS) · incognito mode · history with
filter + bulk re-download · filename templating · self-managing yt-dlp + ffmpeg binaries.
AeroFetch is even with, or ahead of, YTDLnis on all of these.
## Phase L — Media editing (YTDLnis headline features)
YTDLnis's signature differentiator. The keyframe plumbing already exists in AeroFetch
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
`src/main/buildArgs.ts`), so this is cheaper than it looks.
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
player, still TODO).*
- [x] **Split by chapters into separate files.**`--split-chapters` (already flagged as a
TODO in Phase A above). One toggle on `DownloadOptions` +
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
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.*
## Phase M — Queue & daily-use UX ✅ COMPLETE
Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in.
Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
- [x] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
(already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the
`taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so
the close/error handlers stay silent (no error event/log/notification); new
`download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) +
a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting
progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress
row in `QueueItem.tsx`; paused items survive "Clear finished". typecheck + test green;
live resume-continues-the-.part still wants a smoke test.*
- [x] **Reorder / prioritize the queue.** *Done as a **"Download next"** action rather than
drag: since the queue renders newest-first while `pump()` promotes oldest-first, a
reposition primitive is clearer than drag. `prioritize(id)` (`store/downloads.ts`) moves a
queued item to the front of the promotion order; an up-arrow button on queued rows
(`QueueItem.tsx`).*
- [x] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
view summing bytes/speed across active items. Feeds straight into Phase O's taskbar
progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in
`DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested
(`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach —
we don't track per-item sizes — so "time left" is the longest single ETA.*
- [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card
(`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight,
accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url`
Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by
the existing `looksLikeUrl`. A dropped link fills the URL field.*
- [x] **Retry all failed.** One action that re-enqueues every failed item. *Done:
`retryAll()` in `store/downloads.ts` re-queues all `error` items + a "Retry all failed (N)"
button in `DownloadsView.tsx`. (Re-queues live queue items rather than reading
`errorlog.json` — same effect for the on-screen failures.)*
- [x] **Duplicate detection.** Warn ("you already have this") before enqueuing a URL already in
the active queue. *Done: pure `sameVideo()` (`store/queueStats.ts`, YouTube-id aware +
normalised-URL fallback) drives a "Already in your queue — Download anyway?" banner in
`DownloadBar.tsx`; unit-tested. Checks the live queue (which still holds recently-completed
items); cross-checking `history.json` for older downloads is a possible extension.*
- [x] **Per-download scheduling + "save for later."** *Done: a `saved` status + optional
`scheduledFor` (`store/downloads.ts`); `pump()` ignores `saved` (the time gate), and a 15s
ticker promotes due scheduled items to `queued`. UI: a **Schedule** panel with a native
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
AeroFetch is running** — noted in the UI hint and the code.*
## Phase N — Power-user surface ✅ COMPLETE
YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates,
per-item `options`) but not the surfaces. typecheck + test + `npm run build` clean.
- [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with
raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming
stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel`
IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter)
and a Terminal sidebar tab.*
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
default). Per-item exact-format selection (needs probing each entry) left as a further
extension.*
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
- [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex);
`selectExtraArgs` auto-applies a matching template ahead of the global default
(`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern
field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.*
- [x] **Command palette + keyboard shortcuts.** *Done: a portal-free `CommandPalette.tsx`
(filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs,
new download (focuses the `aerofetch-url` input), toggle theme.*
## Phase O — Windows-native integration & discoverability ✅ COMPLETE
All in the main process. typecheck + test + `npm run build` clean.
- [x] **Taskbar progress bar.** *Done: renderer pushes the `summarizeQueue` aggregate over a new
`taskbar:progress` channel (App-level store subscription, no re-render);
`mainWindow.setProgressBar` with `mode: 'normal' | 'error'` (red when any failed) and `-1`
to clear when idle.*
- [x] **System tray + minimize-to-tray.** *Done: `src/main/tray.ts` builds a `Tray` (icon resolved
via `getAppIconPath()`; `build/icon.ico` added to electron-builder `extraResources` for
prod) with Show / Quit. A new `Settings.minimizeToTray` (Settings → Appearance) makes the
window's `close` hide to tray instead of quitting; `before-quit`/tray-Quit set an
`isQuitting` flag so real exits still work.*
- [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] **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.*
## Phase P — Reliability, longevity & trust ✅ (code shipped; cert + manual passes still required)
The least glamorous, highest-leverage track. The buildable code is done (typecheck + test +
build clean); the parts that inherently need a human or a paid cert are delivered as artifacts.
- [x] **PO Token / YouTube reliability plumbing.** *Done: `Settings.youtubePlayerClient` +
`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.
- [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.***
- [ ] **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,
not started.
---
## Post-parity — what we reuse vs. build
**Reuse unchanged:** `buildArgs`/`buildCommand` flag emission, `DownloadOptions` +
`DownloadOptionsForm`, the renderer queue scheduler + concurrency cap + per-item `options`,
the persisted cookies `BrowserWindow`, the probe's `chapters[]`/format data, `errorlog.ts`,
`CommandTemplate`s, `extractIncomingUrl()`, and the self-managing binary updater.
**Build new:** the trim/split UI + `--download-sections`/`--split-chapters` wiring (L), the
pause/reorder/schedule queue states (M), the terminal view + per-item playlist editor (N), the
main-process taskbar/tray/jumplist integration (O), and the PO-token provider (P).
## Post-parity — suggested order
**L is the high-value headline** — trim + split-chapters share one UI surface and the keyframe
plumbing already exists, so they land cheaply and give AeroFetch YTDLnis's marquee capability.
Then **P's PO-token + verify-wiring** as a reliability block (keeps YouTube working and de-risks
shipped features). **M** and **O** are the daily-use polish and pair well (aggregate progress →
taskbar bar). **N** is the power-user surface, each item independently shippable. Each phase is
self-contained and rebuild-gated (`npm run typecheck` + `npm run test` + `npm run build`) the
same way Phases AK were.
+77
View File
@@ -0,0 +1,77 @@
# AeroFetch — Windows code signing
Unsigned builds run, but Windows SmartScreen shows a blue **"Windows protected your PC"**
prompt the first time each user runs the installer (they must click *More info → Run anyway*).
This is the single biggest first-run friction when sharing AeroFetch. Code signing removes it
(EV certificates remove it immediately; OV certificates build reputation over time/installs).
**This step needs a certificate that can't live in the repo**, so it isn't wired on by default.
The build is otherwise ready for it — electron-builder picks the cert up from environment
variables with no `electron-builder.yml` changes required.
## What you need
- An **Authenticode code-signing certificate** for Windows:
- **OV (Organization Validation)** — cheaper; SmartScreen reputation accrues as more users
install, so early downloads still see the prompt.
- **EV (Extended Validation)** — pricier, usually on a hardware token / cloud HSM; clears
SmartScreen immediately. Token-based EV can't be fed as a plain `.pfx` (see "HSM/EV" below).
- The cert as a password-protected `.pfx`/`.p12` (for OV), or an HSM/cloud-signing setup (EV).
## Signing an OV `.pfx` locally
electron-builder reads these env vars automatically:
```bash
# PowerShell
$env:CSC_LINK = "C:\path\to\codesign.pfx" # or a base64 string of the .pfx
$env:CSC_KEY_PASSWORD = "<pfx password>"
npm run build:win
```
```bash
# bash / CI
export CSC_LINK="$(base64 -w0 codesign.pfx)" # base64 is convenient for CI secrets
export CSC_KEY_PASSWORD="$PFX_PASSWORD"
npm run build:win
```
electron-builder then signs `AeroFetch Setup <ver>.exe`, the portable `.exe`, and the app
binary. No changes to `electron-builder.yml` are needed; if `CSC_LINK` is unset, the build
proceeds **unsigned** (current behavior).
## CI (GitHub Actions sketch)
Store the cert + password as encrypted secrets and export them before the build:
```yaml
- name: Build (signed)
env:
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} # base64 of the .pfx
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
run: npm run build:win
```
## HSM / EV certificates
EV certs (and many newer OV certs) ship on a hardware token or cloud HSM and can't be exported
to a `.pfx`. Sign via a custom signing hook instead — set `win.sign` in `electron-builder.yml`
to a script that invokes your provider's tool (Azure Trusted Signing, DigiCert KeyLocker,
SSL.com eSigner, or `signtool` with the token). Each provider documents the exact `signtool`
invocation; electron-builder calls your hook once per artifact with the file path.
## Verifying a signature
```powershell
Get-AuthenticodeSignature ".\dist\AeroFetch Setup <ver>.exe" | Format-List
# Status should be 'Valid'; SignerCertificate should be your cert.
signtool verify /pa /v ".\dist\AeroFetch Setup <ver>.exe"
```
## Notes
- The LGPL ffmpeg + yt-dlp binaries shipped in `resources/bin` are third-party; signing the
AeroFetch artifacts doesn't (and needn't) re-sign them.
- Timestamp the signature (electron-builder does by default) so it stays valid after the cert
expires.
- Signing does **not** replace the smoke test — see [SMOKE-TEST.md](SMOKE-TEST.md).
+61
View File
@@ -0,0 +1,61 @@
# AeroFetch — manual smoke-test checklist
Some features can only be exercised against a **real install** (or a real yt-dlp run) —
they're main-process / Electron / OS-integration code that the Vite browser preview and the
unit suite can't reach. They're implemented and typecheck/test/build clean, but the roadmaps
flag them as needing a hands-on pass. Run this checklist after building
(`npm run build:win`) and installing the NSIS artifact (or launching the portable `.exe`).
Mark each ✅/❌ and note anything surprising.
## Downloads & post-processing (Phases A, L, M)
- [ ] **Basic download** — paste a URL, Fetch, pick a format, Download → file lands in
Documents\Video (or your folder), progress + speed update, "Completed".
- [ ] **Trim / cut** (Phase L) — set a section like `0:10-0:20`, download → output is ~10s and
starts/ends near the cut points (`--download-sections` + `--force-keyframes-at-cuts`).
- [ ] **Split by chapters** (Phase L) — on a video with chapters, enable it → per-chapter files
appear alongside the full file.
- [ ] **Pause / resume** (Phase M) — start a large download, Pause (process stops, `.part`
stays), Resume → it **continues** from where it stopped, not from 0% (yt-dlp `--continue`).
- [ ] **Scheduling** (Phase M) — schedule a download a couple of minutes out → it sits "Saved /
Scheduled", then auto-starts at the time (only while the app is running).
## Access & networking (Phase B, P)
- [ ] **Cookies — sign-in window** — Settings → Cookies → "Sign-in window", log into a site,
close it → "cookies saved" with a timestamp; a members-only/age-gated video then downloads.
- [ ] **Cookies — from browser** — pick an installed browser → a gated video downloads.
- [ ] **Proxy** — set a working proxy → downloads route through it (verify via the proxy logs).
- [ ] **aria2c** — drop `aria2c.exe` into `resources/bin`, enable it → multi-connection download
works; remove it → silently falls back to yt-dlp's downloader.
- [ ] **YouTube client / PO token** (Phase P) — set `youtubePlayerClient` (e.g. `web_safari`) →
the run includes `--extractor-args youtube:player_client=web_safari` (check Terminal/Preview).
## OS integration (Phase E, J, O)
- [ ] **`aerofetch://` protocol** — from a browser, open `aerofetch://download?url=<encoded>`
AeroFetch focuses and the URL appears in the "Link received" banner.
- [ ] **Explorer "Send to AeroFetch"** — drag a tab to the desktop to make a `.url`, right-click →
Send to → AeroFetch → the link is received.
- [ ] **Scheduled sync** (Phase J) — Settings → enable the daily sync → a Windows Task Scheduler
task exists; run it (or `AeroFetch.exe --sync`) → watched sources index and new items enqueue.
- [ ] **RSS fast-check** (Phase J) — "Check for new" on a watched channel → newly-posted videos
show as new without a full re-index.
- [ ] **System tray** (Phase O) — enable "Keep running in the tray", close the window → it hides
to the tray; tray click reopens; tray → Quit actually exits.
- [ ] **Taskbar progress** (Phase O) — during downloads the taskbar icon shows a progress bar;
it turns red if one fails and clears when the queue goes idle.
- [ ] **Jump list** (Phase O) — right-click the taskbar icon → "Open AeroFetch" focuses the app.
## Terminal (Phase N)
- [ ] Enable "Run custom commands", open Terminal, run `--version` → the version prints; run
`-F <url>` → the format table streams in; Stop cancels a long run.
---
If anything here fails, capture the Settings → Diagnostics "Copy full report" output and the
exact steps. These items have no automated coverage, so this checklist is the release gate for
them. See also [SIGNING.md](SIGNING.md) for the code-signing step that removes the SmartScreen
prompt for end users.
+4
View File
@@ -30,6 +30,10 @@ extraResources:
to: bin
filter:
- '**/*'
# The app icon, copied so the runtime (system tray) can load it at
# <resources>/icon.ico — build/ itself isn't bundled into the app.
- from: build/icon.ico
to: icon.ico
win:
# Two artifacts:
+30 -2
View File
@@ -1,16 +1,17 @@
{
"name": "aerofetch",
"version": "0.1.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aerofetch",
"version": "0.1.0",
"version": "0.4.1",
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -3373,6 +3374,33 @@
"node": ">=10"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aerofetch",
"version": "0.2.0",
"version": "0.5.0",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
@@ -25,6 +25,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+6 -1
View File
@@ -2,6 +2,7 @@ import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation'
import type {
BackupExportResult,
BackupImportResult,
@@ -55,8 +56,12 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
file.settings && typeof file.settings === 'object'
? (file.settings as Partial<Settings>)
: undefined
// Drop non-object / id-less entries up front (audit T3) so both the consent
// check below and replaceTemplates see only well-shaped rows. Without this a
// backup with a null/garbage templates entry throws in sanitize() instead of
// degrading gracefully as this function promises.
const incomingTemplates = Array.isArray(file.templates)
? (file.templates as CommandTemplate[])
? (file.templates as CommandTemplate[]).filter(isTemplateLike)
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
+45 -1
View File
@@ -13,10 +13,42 @@ export function getBinDir(): string {
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
}
export function getYtdlpPath(): string {
/**
* The app icon (.ico), for the system tray. In dev it's the repo's build/icon.ico;
* in a packaged build, electron-builder's extraResources copies it to
* <resources>/icon.ico (see electron-builder.yml).
*/
export function getAppIconPath(): string {
return is.dev
? join(app.getAppPath(), 'build', 'icon.ico')
: join(process.resourcesPath, 'icon.ico')
}
/**
* 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
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
* portable re-extraction — which only ever replace the bundled seed — can never
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
*
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
*/
export function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
}
/** The bundled, read-only yt-dlp.exe seeded into the managed copy when missing. */
export function getBundledYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe')
}
/** The managed (spawned + auto-updated) yt-dlp.exe under userData. */
export function getYtdlpPath(): string {
return join(getManagedBinDir(), 'yt-dlp.exe')
}
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
@@ -35,3 +67,15 @@ export function getFfprobePath(): string {
export function getAria2cPath(): string {
return join(getBinDir(), 'aria2c.exe')
}
/**
* Absolute path to a Windows system executable (e.g. taskkill.exe, schtasks.exe).
*
* SECURITY (audit F3): system tools are resolved by full path under System32
* rather than by bare name, so a same-named binary planted in the current
* working directory or earlier on PATH can't be invoked in their place — a real
* risk for the portable build, which runs from user-writable locations.
*/
export function getSystem32Path(exe: string): string {
return join(process.env.SystemRoot || 'C:\\Windows', 'System32', exe)
}
+112 -5
View File
@@ -14,7 +14,8 @@ import {
type StartDownloadOptions,
type DownloadOptions,
type CollectionContext,
type CookieBrowser
type CookieBrowser,
type CommandTemplate
} from '@shared/ipc'
// --- Quality → yt-dlp selector mapping --------------------------------------
@@ -99,6 +100,10 @@ export interface AccessOptions {
restrictFilenames: boolean
/** absolute path to a --download-archive file; omit to disable archive tracking */
downloadArchivePath?: string
/** YouTube extraction client override (e.g. 'web_safari'); empty/omitted = default */
youtubePlayerClient?: string
/** manually-supplied YouTube Proof-of-Origin token; empty/omitted = none */
youtubePoToken?: string
}
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
@@ -127,6 +132,83 @@ export function parseExtraArgs(raw: string): string[] {
return args
}
/**
* Resolve the extra yt-dlp args for a download, enforcing the custom-command
* consent gate (audit F2).
*
* Extra args are powerful enough to run arbitrary code (e.g. `--exec`), so they
* are honoured ONLY when custom commands are explicitly enabled in settings —
* the same persisted flag the Settings UI and backup-import treat as consent. A
* per-download override wins over the persisted default template, but NEITHER is
* applied while the gate is off. This keeps a compromised renderer from smuggling
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
*
* - customCommandEnabled off → [] (always)
* - perDownloadExtraArgs defined (even '') → those args
* - else a template whose urlPattern matches → that template's args (most specific)
* - else a matching defaultTemplateId → that template's args
* - else → []
*/
export function selectExtraArgs(params: {
customCommandEnabled: boolean
perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
/** the download URL, for urlPattern auto-matching */
url?: string
}): string[] {
if (!params.customCommandEnabled) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
// A template whose urlPattern matches this URL auto-applies, ahead of the global
// default — it's the more specific choice.
if (params.url) {
const matched = params.templates.find(
(t) => t.urlPattern && matchesUrl(t.urlPattern, params.url!)
)
if (matched) return parseExtraArgs(matched.args)
}
if (params.defaultTemplateId) {
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
}
/** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */
export function matchesUrl(pattern: string, url: string): boolean {
try {
return new RegExp(pattern, 'i').test(url)
} catch {
return false
}
}
/**
* Parse a raw trim string (one or more time ranges, comma- or newline-separated)
* into yt-dlp `--download-sections` specs. Each range is normalised to the
* `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a
* timestamp range from a chapter-title regex). Tokens that don't look like a
* numeric time range are dropped, so malformed input can't reach yt-dlp's argv.
*
* Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00',
* '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`.
*/
export function parseTrimSections(raw: string | undefined): string[] {
if (!raw) return []
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
const out: string[] = []
for (const piece of raw.split(/[,\n]/)) {
let t = piece.trim()
if (!t) continue
if (t.startsWith('*')) t = t.slice(1).trim()
if (RANGE.test(t)) out.push(`*${t}`)
}
return out
}
// Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string {
@@ -163,7 +245,9 @@ export function sanitizeDirSegment(name: string): string {
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)) s = `_${s}`
// 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}`
s = s.slice(0, 80).trim()
return s || 'Untitled'
}
@@ -198,6 +282,12 @@ function accessArgs(a: AccessOptions): string[] {
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
if (a.restrictFilenames) args.push('--restrict-filenames')
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
// YouTube reliability: combine the player-client + PO-token overrides into one
// `youtube:` extractor-args group (semicolon-separated, as yt-dlp expects).
const yt: string[] = []
if (a.youtubePlayerClient?.trim()) yt.push(`player_client=${a.youtubePlayerClient.trim()}`)
if (a.youtubePoToken?.trim()) yt.push(`po_token=${a.youtubePoToken.trim()}`)
if (yt.length > 0) args.push('--extractor-args', `youtube:${yt.join(';')}`)
return args
}
@@ -214,17 +304,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
}
// SponsorBlock.
let forcedKeyframes = false
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
forcedKeyframes = true
} else {
args.push('--sponsorblock-mark', cats)
}
}
// Trim — download only the given time ranges (works for video + audio). Force
// keyframes at the cut points for frame-accurate boundaries, unless
// SponsorBlock-remove already requested it (the flag is idempotent, but we
// avoid emitting it twice).
const sections = parseTrimSections(opts.trim)
for (const sec of sections) args.push('--download-sections', sec)
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
if (o.embedChapters) args.push('--embed-chapters')
// 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')
// Media-server sidecar files (Phase K) — written next to the output file so
@@ -274,9 +377,13 @@ export function buildArgs(
}
} else {
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
// pick toward the chosen codec without overriding the requested quality.
if (o.preferredVideoCodec !== 'any') {
// A raw format-sort string (advanced) wins outright; otherwise the codec
// preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
// the chosen codec without overriding the requested quality.
const sort = o.formatSort.trim()
if (sort) {
args.push('-S', sort)
} else if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`)
}
+56 -21
View File
@@ -1,4 +1,4 @@
import { app, session, BrowserWindow, type Cookie } from 'electron'
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
@@ -11,6 +11,55 @@ import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
*/
const PARTITION = 'persist:aerofetch-login'
/**
* The sign-in window renders untrusted remote content, so every navigation and
* popup is confined to web URLs — http(s), plus about:blank. This stops a
* logged-in (or malicious) page from steering the window to a file:// URL, to
* the app's own aerofetch:// protocol handler, or to any other external URI
* scheme used as a pivot. (audit T4)
*/
export function isAllowedLoginUrl(target: string): boolean {
try {
const { protocol } = new URL(target)
return protocol === 'http:' || protocol === 'https:' || protocol === 'about:'
} catch {
return false
}
}
/**
* Apply the web-only confinement to a sign-in webContents: block top-frame
* navigations and server redirects to non-web URLs, restrict popups to web URLs
* (reusing the same secure, partitioned webPreferences), and recurse into any
* popup the page opens so a nested window can't escape the policy either. The
* window-open handler alone only governs NEW windows, not navigations of an
* existing one — both vectors are covered here. (audit T4)
*/
function hardenLoginWebContents(wc: WebContents): void {
wc.setWindowOpenHandler((details) => {
if (!isAllowedLoginUrl(details.url)) return { action: 'deny' }
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
}
}
})
wc.on('will-navigate', (e, navUrl) => {
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
})
wc.on('will-redirect', (e, navUrl) => {
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
})
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
}
export function getCookiesFilePath(): string {
return join(app.getPath('userData'), 'cookies.txt')
}
@@ -114,26 +163,12 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
}
loginWindow = win
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
// sharing the same partition, rather than silently swallowing the click.
// Restrict popups to http(s) only — the same defence the main window applies
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
// to disk or use a custom-protocol popup as a pivot.
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
} catch {
return { action: 'deny' } // unparseable URL — never open it
}
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
}
}
})
// Confine every navigation/popup to web URLs (recursively, so OAuth/SSO
// popups sharing the cookie partition are covered too), and deny all gated
// web permissions — signing in needs no camera/mic/geolocation/etc., and the
// page is untrusted. (audit T4)
hardenLoginWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
win.on('closed', () => {
loginWindow = null
+21 -8
View File
@@ -1,27 +1,40 @@
import { app, shell, type BrowserWindow } from 'electron'
import { existsSync, readFileSync } from 'fs'
import { existsSync, openSync, readSync, closeSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
/** Only ever forward http(s) targets into the app — same restriction the
* external-link window-open handler in index.ts applies to in-page links. */
* external-link window-open handler in index.ts applies to in-page links.
* Delegates to the download path's guard so an incoming deep-link target is
* validated AND parser-normalised (no embedded tabs/newlines/control chars
* reach the renderer); returns null instead of throwing for the argv scan.
* (audit T3 / F5) */
function asHttpUrl(candidate: string): string | null {
try {
const u = new URL(candidate)
return u.protocol === 'http:' || u.protocol === 'https:' ? candidate : null
return assertHttpUrl(candidate)
} catch {
return null
}
}
/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's
* "Send to" menu hands us when the user sends a saved link to AeroFetch. */
* "Send to" menu hands us when the user sends a saved link to AeroFetch.
* Only the first 64 KB is read: a real shortcut is a few hundred bytes, so this
* caps memory and limits exposure if argv points at a pathological or oversized
* file that merely ends in `.url`. (audit T5) */
const MAX_URL_FILE_BYTES = 64 * 1024
function readUrlShortcut(path: string): string | null {
let fd: number | null = null
try {
const text = readFileSync(path, 'utf8')
const match = /^URL=(.+)$/im.exec(text)
fd = openSync(path, 'r')
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
} catch {
return null
} finally {
if (fd !== null) closeSync(fd)
}
}
@@ -32,7 +45,7 @@ function readUrlShortcut(path: string): string | null {
*/
export function extractIncomingUrl(argv: string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('aerofetch://')) {
if (/^aerofetch:\/\//i.test(arg)) {
try {
const target = new URL(arg).searchParams.get('url')
const valid = target && asHttpUrl(target)
+92 -28
View File
@@ -1,15 +1,24 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
getYtdlpPath,
getBinDir,
getAria2cPath,
getFfmpegPath,
getFfprobePath,
getSystem32Path
} from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation'
import {
buildArgs,
parseExtraArgs,
selectExtraArgs,
formatCommandLine,
collectionOutputTemplate
} from './buildArgs'
@@ -29,6 +38,7 @@ import {
interface ActiveDownload {
child: ChildProcess
canceled: boolean
paused: boolean
}
const active = new Map<string, ActiveDownload>()
@@ -151,21 +161,35 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
// --- Argv construction (shared by startDownload and the command preview) ---
// A per-download override (opts.extraArgs, even '') always wins; otherwise
// fall back to the persisted default template when custom-command mode is on.
// A per-download override (opts.extraArgs, even '') wins over the persisted
// default template — but BOTH are gated on the customCommandEnabled consent flag
// (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[] {
if (opts.extraArgs !== undefined) return parseExtraArgs(opts.extraArgs)
if (settings.customCommandEnabled && settings.defaultTemplateId) {
const tpl = listTemplates().find((t) => t.id === settings.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
return selectExtraArgs({
customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs,
defaultTemplateId: settings.defaultTemplateId,
templates: listTemplates(),
url: opts.url
})
}
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
// 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
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
//
// A per-download outputDir override must clear the same safety check the persisted
// setting does (absolute path only); a renderer-supplied override is otherwise
// untrusted — it dictates where downloaded files get written. An unsafe override is
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
const override = opts.outputDir?.trim()
const safeOverride = override && isSafeOutputDir(override) ? override : ''
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
// A collection (media-manager) download is filed into <channel>/<playlist>/
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
@@ -190,7 +214,9 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
cookiesFile,
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
youtubePlayerClient: settings.youtubePlayerClient,
youtubePoToken: settings.youtubePoToken
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
@@ -198,13 +224,16 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
/** Build the exact command line for the current form state, without running it. */
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
let normalized: StartDownloadOptions
try {
assertHttpUrl(opts.url)
// Use the parser-normalised URL (audit F5), so the previewed command matches
// exactly what startDownload would spawn.
normalized = { ...opts, url: assertHttpUrl(opts.url) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
try {
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(opts)) }
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(normalized)) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -216,11 +245,14 @@ 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()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).`
}
}
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
@@ -239,9 +271,13 @@ export function startDownload(
`Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
// and replace opts.url with the parser-normalised form so the exact string we
// validated is the one that gets spawned/probed — not a raw variant carrying
// interior tabs/newlines or leading control chars that URL parsing silently
// tolerates. (audit F5)
try {
assertHttpUrl(opts.url)
opts = { ...opts, url: assertHttpUrl(opts.url) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -263,7 +299,7 @@ export function startDownload(
return { ok: false, error: (e as Error).message }
}
const rec: ActiveDownload = { child, canceled: false }
const rec: ActiveDownload = { child, canceled: false, paused: false }
active.set(opts.id, rec)
// Title/channel/duration are kept locally so the completion notification and
@@ -312,7 +348,8 @@ export function startDownload(
if (settled) return
settled = true
active.delete(opts.id)
if (!rec.canceled) {
// A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message)
@@ -323,7 +360,9 @@ export function startDownload(
if (settled) return
settled = true
active.delete(opts.id)
if (rec.canceled) return // renderer already showed 'canceled' optimistically
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event.
if (rec.canceled || rec.paused) return
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
@@ -338,15 +377,40 @@ export function startDownload(
return { ok: true }
}
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
// taskkill from System32 by absolute path, never the bare name, so a planted
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
function killTree(rec: ActiveDownload): void {
const pid = rec.child.pid
if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too.
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {})
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
rec.child.kill()
}
}
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
killTree(rec)
}
/**
* Pause a running download: kill the process tree but flag it `paused` (not
* `canceled`) so the close handler stays silent — no error event, no error log,
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
* is just a fresh startDownload with the same options: yt-dlp's default
* `--continue` picks the partial file back up.
*/
export function pauseDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.paused = true
killTree(rec)
}
+36
View File
@@ -0,0 +1,36 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getFfmpegPath, getFfprobePath } from './binaries'
import type { FfmpegVersionResult } from '@shared/ipc'
/**
* Read the version token from an ffmpeg/ffprobe binary. Both print a first line
* of the form "<tool> version <VERSION> Copyright ..." (e.g.
* "ffmpeg version 6.1.1-full_build-www.gyan.dev Copyright ..."), so we return the
* token after "version". Resolves null if the binary is absent, errors, times
* out, or the line doesn't parse — Settings then shows "not found" instead of
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
*/
function readToolVersion(path: string): Promise<string | null> {
if (!existsSync(path)) return Promise.resolve(null)
return new Promise((resolve) => {
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const firstLine = stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
resolve(m ? m[1] : null)
})
})
}
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
const [ffmpeg, ffprobe] = await Promise.all([
readToolVersion(getFfmpegPath()),
readToolVersion(getFfprobePath())
])
return { ffmpeg, ffprobe }
}
+100 -4
View File
@@ -8,12 +8,16 @@ import {
type HistoryEntry,
type CommandTemplate,
type YtdlpUpdateChannel,
type SystemThemeInfo
type SystemThemeInfo,
type TaskbarProgress
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } from './settings'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { runTerminal, cancelTerminal } from './terminal'
import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
@@ -33,6 +37,7 @@ import {
import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
import { createTray, markQuitting, isQuitting } from './tray'
// 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
@@ -90,6 +95,25 @@ function getSystemThemeInfo(): SystemThemeInfo {
}
}
// Web permissions a download manager never needs. They're denied for the app
// window as defence-in-depth (audit T6): even if the renderer were compromised
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
// location, or reach USB/HID/serial/Bluetooth devices — none of which the IPC
// surface grants either. Clipboard (paste/copy) and everything else is left to
// the default so the app's own features keep working.
const DENIED_PERMISSIONS = new Set([
'media', // camera + microphone
'geolocation',
'midi',
'midiSysex',
'hid',
'serial',
'usb',
'bluetooth',
'speaker-selection',
'idle-detection'
])
function createWindow(): void {
const win = new BrowserWindow({
width: 920,
@@ -113,6 +137,15 @@ function createWindow(): void {
else win.show()
})
// Minimize-to-tray: when enabled, closing the window hides it to the tray
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
win.on('close', (e) => {
if (getSettings().minimizeToTray && !isQuitting()) {
e.preventDefault()
win.hide()
}
})
win.on('closed', () => {
if (mainWindow === win) mainWindow = null
})
@@ -143,6 +176,15 @@ function createWindow(): void {
// away from it (defence in depth; HMR uses websockets, not navigation).
win.webContents.on('will-navigate', (e) => e.preventDefault())
// Deny the sensitive hardware/location web permissions the app never uses, so
// a compromised renderer can't escalate to capabilities the IPC surface
// doesn't grant. Both the async request and the sync check are covered. (audit T6)
const ses = win.webContents.session
ses.setPermissionRequestHandler((_wc, permission, callback) =>
callback(!DENIED_PERMISSIONS.has(permission))
)
ses.setPermissionCheckHandler((_wc, permission) => !DENIED_PERMISSIONS.has(permission))
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
@@ -151,8 +193,18 @@ function createWindow(): void {
}
function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
downloadAppUpdate(url, e.sender)
)
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
@@ -171,6 +223,11 @@ function registerIpcHandlers(): void {
return result
})
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
runTerminal(e.sender, id, args)
)
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
@@ -275,6 +332,13 @@ function registerIpcHandlers(): void {
)
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
// 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 })
})
}
// Push OS theme/contrast changes to every window, and keep the native
@@ -303,6 +367,10 @@ if (isPrimaryInstance) {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
// Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind).
ensureMediaDirs()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
@@ -311,12 +379,40 @@ if (isPrimaryInstance) {
registerSystemThemeBridge()
registerSendToShortcut()
createWindow()
createTray(() => mainWindow)
// Taskbar right-click jump list: a quick "Open AeroFetch" task. Launching the
// exe again is caught by the single-instance lock, which focuses this window.
app.setUserTasks([
{
program: process.execPath,
arguments: '',
title: 'Open AeroFetch',
description: 'Open the AeroFetch window',
iconPath: process.execPath,
iconIndex: 0
}
])
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
// once a day) self-update it to the chosen channel so a stale binary can't
// silently cause YouTube 403s. Best-effort and off the critical path — it
// never blocks startup, and status is pushed to the window if it's listening.
runStartupYtdlpAutoUpdate((status) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
}
}).catch(() => {})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 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()
+3 -2
View File
@@ -83,8 +83,9 @@ export async function indexSource(
url: string,
onProgress: (p: IndexProgress) => void
): Promise<IndexSourceResult> {
let normalizedUrl: string
try {
assertHttpUrl(url)
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -146,7 +147,7 @@ export async function indexSource(
// resolve to a playlist when probed. A lone video has no entries → error.
kind = cls?.kind ?? 'playlist'
onProgress({ url, phase: 'uploads', message: 'Indexing playlist…' })
const data = await probeFlat(cls?.base ?? url)
const data = await probeFlat(cls?.base ?? normalizedUrl)
const entries = data.entries ?? []
if (entries.length === 0) {
return {
+21 -1
View File
@@ -34,7 +34,9 @@ export interface RawEntry {
export function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
// e.id is untrusted JSON from yt-dlp, so percent-encode it rather than splicing
// it raw into the query string (audit T2). A normal 11-char id is unaffected.
if (e.id) return `https://www.youtube.com/watch?v=${encodeURIComponent(e.id)}`
return null
}
@@ -138,6 +140,24 @@ export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string
return `https://www.youtube.com/feeds/videos.xml?${param}=${encodeURIComponent(ytId)}`
}
/**
* Guard for the watched-source RSS pre-check (audit T7). The only feed AeroFetch
* ever builds is a YouTube videos.xml feed (see buildFeedUrl), so the sync refuses
* to fetch anything else — this keeps a hand-edited / corrupted sources.json from
* pointing fetch() at an internal service, a cloud-metadata endpoint, or any other
* arbitrary host (SSRF). Requires https, the youtube.com host (www optional), and
* the exact feed path; the channel_id/playlist_id query is free.
*/
export function isYouTubeFeedUrl(url: string): boolean {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
return u.protocol === 'https:' && host === 'youtube.com' && u.pathname === '/feeds/videos.xml'
} catch {
return false
}
}
/** Extract the video ids from a YouTube RSS/Atom feed body (the <yt:videoId> tags). */
export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = []
+3 -2
View File
@@ -118,8 +118,9 @@ export function probeMedia(url: string): Promise<ProbeResult> {
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
})
}
let target: string
try {
assertHttpUrl(url)
target = assertHttpUrl(url) // normalised form (audit F5)
} catch (e) {
return Promise.resolve({ ok: false, error: (e as Error).message })
}
@@ -128,7 +129,7 @@ export function probeMedia(url: string): Promise<ProbeResult> {
execFile(
ytdlp,
// `--` terminates option parsing so the URL can never be read as a flag.
['-J', '--flat-playlist', '--no-warnings', '--', url],
['-J', '--flat-playlist', '--no-warnings', '--', target],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
(err, stdout, stderr) => {
if (err) {
+4 -1
View File
@@ -10,6 +10,7 @@
*/
import { execFile } from 'child_process'
import { getSystem32Path } from './binaries'
import type { ScheduledSyncStatus } from '@shared/ipc'
const TASK_NAME = 'AeroFetchDailySync'
@@ -18,7 +19,9 @@ export const SYNC_FLAG = '--sync'
function schtasks(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
execFile('schtasks', args, { windowsHide: true }, (err, stdout, stderr) => {
// 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) })
})
+67 -8
View File
@@ -1,5 +1,6 @@
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
@@ -10,13 +11,17 @@ import {
COOKIE_BROWSERS,
ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS,
isYtdlpUpdateChannel,
type Settings,
type DownloadOptions,
type SponsorBlockCategory
} from '@shared/ipc'
const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoDir: '',
audioDir: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -31,13 +36,21 @@ const DEFAULTS: Settings = {
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
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).
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: false
hasCompletedOnboarding: false,
minimizeToTray: false
}
/** Fixed path for the --download-archive file; not user-configurable. */
@@ -45,6 +58,31 @@ export function getDownloadArchivePath(): string {
return join(app.getPath('userData'), 'download-archive.txt')
}
/**
* The default per-kind download destination: video → Documents\Video,
* audio → Documents\Audio. Used when the user hasn't set an explicit output
* folder (Settings → Download folder), so downloads are sorted by type.
*/
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
}
/**
* Create the Documents\Video and Documents\Audio folders up front (called once
* at startup) so they exist the moment the app opens, not just after the first
* download. Best-effort: yt-dlp also creates the output dir at download time, so
* a failure here (read-only Documents, redirected folder) is non-fatal.
*/
export function ensureMediaDirs(): void {
for (const kind of ['video', 'audio'] as const) {
try {
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
} catch {
/* non-fatal — the download path will be created on demand instead */
}
}
}
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
// defaults for any missing/invalid field. Used both to migrate older settings
// files (which predate downloadOptions) and to validate renderer writes.
@@ -66,6 +104,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
? o.preferredVideoCodec!
: d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
@@ -76,6 +115,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
@@ -100,10 +140,9 @@ export function getSettings(): Settings {
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
if (!cur.outputDir) {
// Fill in the real Downloads path the first time, and persist it once.
s.set('outputDir', app.getPath('downloads'))
}
// videoDir/audioDir are intentionally left blank by default — an empty value
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
// Only an explicit user choice (Settings → folders) overrides that.
// Migrate settings files that predate downloadOptions (or hold a partial one),
// but only persist when sanitizing actually altered the stored value.
const sanitized = sanitizeOptions(cur.downloadOptions)
@@ -165,12 +204,25 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
case 'autoUpdateYtdlp':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
case 'minimizeToTray':
if (typeof value === 'boolean') s.set(key, value)
break
case 'ytdlpChannel':
// Same allowlist that guards the `--update-to` flag (audit F1).
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
break
case 'ytdlpLastUpdateCheck': {
// Set by the main-process auto-updater; validated here since setSettings
// is the only writer. A bogus value at worst skips/forces one check.
const n = Number(value)
if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n)
break
}
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break
@@ -187,8 +239,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
// group (it merges field changes locally before calling setSettings).
s.set('downloadOptions', sanitizeOptions(value))
break
case 'outputDir':
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
case 'videoDir':
case 'audioDir':
// An empty string is allowed — it clears the override and restores the
// Documents\Video / Documents\Audio default for that kind.
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
s.set(key, value.trim())
}
break
case 'filenameTemplate':
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
@@ -201,6 +258,8 @@ export function setSettings(partial: Partial<Settings>): Settings {
// and exported by exportBackup as-is — documented, not masked.
case 'proxy':
case 'rateLimit':
case 'youtubePlayerClient':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, value)
break
}
+5 -1
View File
@@ -7,11 +7,15 @@
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
import { parseRssVideoIds } from './indexerCore'
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
async function fetchFeedIds(feedUrl: string): Promise<string[]> {
// Only ever fetch a genuine YouTube feed — refuse an arbitrary host that a
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
// caught by the caller, which then falls back to a full yt-dlp re-index.
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
if (!res.ok) throw new Error(`feed responded ${res.status}`)
return parseRssVideoIds(await res.text())
+4 -1
View File
@@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void {
}
function sanitize(t: CommandTemplate): CommandTemplate {
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
return {
id: String(t.id),
name: (t.name ?? '').trim() || 'Untitled command',
args: (t.args ?? '').trim()
args: (t.args ?? '').trim(),
// Only persist a urlPattern when one was provided, keeping older files clean.
...(urlPattern ? { urlPattern } : {})
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed
* arguments and streams stdout/stderr back to the renderer line-by-line.
*
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
*/
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { parseExtraArgs } from './buildArgs'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>()
function send(wc: WebContents, ev: TerminalEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev)
}
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
if (!getSettings().customCommandEnabled) {
return {
ok: false,
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
}
}
ensureManagedYtdlp()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
}
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
let child: ChildProcess
try {
child = spawn(ytdlp, args, { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
active.set(id, child)
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
buffers[stream] += chunk
let nl: number
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
buffers[stream] = buffers[stream].slice(nl + 1)
send(wc, { type: 'output', id, line, stream })
}
}
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
let settled = false
child.on('error', (err) => {
if (settled) return
settled = true
active.delete(id)
send(wc, { type: 'error', id, error: err.message })
})
child.on('close', (code) => {
if (settled) return
settled = true
active.delete(id)
// Flush any trailing partial lines that never hit a newline.
for (const stream of ['stdout', 'stderr'] as const) {
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
}
send(wc, { type: 'done', id, code })
})
return { ok: true }
}
export function cancelTerminal(id: string): void {
const child = active.get(id)
if (!child) return
const pid = child.pid
if (pid != null) {
// Tree-kill via System32 taskkill by absolute path (same hardening as download.ts).
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
child.kill()
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* System tray + minimize-to-tray (Phase O). When Settings.minimizeToTray is on,
* closing the window hides it to the tray instead of quitting; the tray's "Quit"
* really exits. The tray gives the app a background presence (a natural home for
* the watched-source sync) and a quick show/quit menu.
*/
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
import { getAppIconPath } from './binaries'
let tray: Tray | null = null
// True once the user has chosen to really quit (tray menu, or app.quit from the
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
let quitting = false
export function isQuitting(): boolean {
return quitting
}
export function markQuitting(): void {
quitting = true
}
function show(getWindow: () => BrowserWindow | null): void {
const win = getWindow()
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
export function createTray(getWindow: () => BrowserWindow | null): void {
if (tray) return
const icon = nativeImage.createFromPath(getAppIconPath())
// Without a usable icon a Windows tray entry is invisible/unclickable, which is
// worse than no tray — so skip it rather than ship a dead tray.
if (icon.isEmpty()) return
tray = new Tray(icon)
tray.setToolTip('AeroFetch')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show AeroFetch', click: () => show(getWindow) },
{ type: 'separator' },
{
label: 'Quit AeroFetch',
click: () => {
quitting = true
app.quit()
}
}
])
)
tray.on('click', () => show(getWindow))
}
+394
View File
@@ -0,0 +1,394 @@
import { app, net, shell, type WebContents } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { stat, unlink } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { createHash } from 'crypto'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress
} from '@shared/ipc'
// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
// the repo's latest release over the public REST API and downloads the installer
// asset attached to it.
//
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
// AeroFetch never ships a token; on a sign-in-required instance the check simply
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
const UPDATE_OWNER = 'debont80'
const UPDATE_REPO = 'AeroFetch'
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
// Security: only ever download or execute a file served by the trusted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
// release downloads to its attachment store — so this is re-checked on EVERY
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
// response from bouncing us off the trusted host.
export function isTrustedDownloadUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
} catch {
return false
}
}
/** Where downloaded installers are written (and the only dir we'll run one from). */
function updateDir(): string {
return app.getPath('temp')
}
/**
* Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
* release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
* the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
* release (0.5.0): at most it compares equal, so we won't offer a prerelease as
* an "upgrade" over the same shipped version.
*/
function parseVersion(v: string): number[] {
return v
.replace(/^v/i, '')
.split(/[-+]/)[0]
.split('.')
.map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n))
}
/** Component-wise numeric compare: -1 if a<b, 0 if equal, 1 if a>b. */
export function compareVersions(a: string, b: string): number {
const pa = parseVersion(a)
const pb = parseVersion(b)
const len = Math.max(pa.length, pb.length)
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0
const y = pb[i] ?? 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
interface GiteaAsset {
name: string
browser_download_url: string
}
interface GiteaRelease {
tag_name: string
body?: string
html_url?: string
assets?: GiteaAsset[]
}
/**
* Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
* 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).
*/
export function extractSha256(text: string): string | null {
const m = text.match(/\b[a-f0-9]{64}\b/i)
return m ? m[0].toLowerCase() : null
}
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
const exes = assets.filter((a) => /\.exe$/i.test(a.name))
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
}
/** Query the configured repo's latest release and compare it to the running app. */
export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const currentVersion = app.getVersion()
// Bound the check so a stalled/unreachable server can't hang the UI spinner
// indefinitely — mirrors the timeouts on the yt-dlp calls.
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) {
const auth = res.status === 401 || res.status === 403 || res.status === 404
return {
ok: false,
available: false,
currentVersion,
error: auth
? 'Could not reach the update server (it may require sign-in for anonymous access).'
: `Update check failed (HTTP ${res.status}).`
}
}
const rel = (await res.json()) as GiteaRelease
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
const asset = pickInstaller(rel.assets ?? [])
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
return {
ok: true,
available,
currentVersion,
latestVersion,
notes: rel.body?.trim() || undefined,
// Only ever hand the OS browser a release page on our own trusted host.
htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
downloadUrl: asset?.browser_download_url,
assetName: asset?.name
}
} catch (e) {
const timedOut = e instanceof Error && e.name === 'AbortError'
return {
ok: false,
available: false,
currentVersion,
error: timedOut
? 'Update check timed out — the server took too long to respond.'
: e instanceof Error
? e.message
: String(e)
}
} finally {
clearTimeout(timer)
}
}
/** How long the download may stall (no bytes received) before we give up. */
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
// to run an installer we can't verify against it. Note this is INTEGRITY +
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
// bounced to other storage), NOT protection against a fully compromised host —
// that host serves both files, so only Authenticode signing defends against it.
const REQUIRE_CHECKSUM: boolean = true
/**
* GET a small text resource from the trusted host, re-validating the host on
* every redirect hop (same discipline as the installer download) and capping the
* body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
*/
function fetchTrustedText(
url: string,
maxBytes = 64 * 1024,
timeoutMs = 15_000
): 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 => {
if (settled) return
settled = true
clearTimeout(timer)
if (!r.ok) request.abort()
resolve(r)
}
const request = net.request({ url, redirect: 'manual' })
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
request.on('redirect', (_s, _m, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
done({ ok: false, error: 'redirect to an untrusted location' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
done({ ok: false, status, error: `HTTP ${status}` })
return
}
const chunks: Buffer[] = []
let size = 0
response.on('data', (c: Buffer) => {
size += c.length
if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
else chunks.push(c)
})
response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
response.on('error', (e: Error) => done({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => done({ ok: false, error: e.message }))
request.end()
})
}
/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(url: string, wc: WebContents): Promise<AppUpdateDownload> {
if (!isTrustedDownloadUrl(url)) {
return { ok: false, error: 'Refused to download from an untrusted location.' }
}
// Derive a safe .exe filename from the URL; fall back to a fixed name.
const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe'
const filePath = join(updateDir(), safeName)
// Resolve the published checksum up front (tiny, fails fast) so we never pull a
// ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
// so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
const checksumUrl = (() => {
const u = new URL(url)
u.pathname += '.sha256'
return u.toString()
})()
const sum = await fetchTrustedText(checksumUrl)
let expectedSha: string | null = null
if (sum.ok) {
expectedSha = extractSha256(sum.text)
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
} else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) {
return {
ok: false,
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
}
}
} else {
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
}
return new Promise<AppUpdateDownload>((resolve) => {
let settled = false
let fileStream: WriteStream | null = null
let idle: NodeJS.Timeout | null = null
// Single teardown point. On failure it also aborts the request, so a write
// error (disk full, etc.) can't leave Electron pulling bytes into a dead
// stream. abort() is idempotent, so the call sites below don't repeat it.
const finish = (result: AppUpdateDownload): void => {
if (settled) return
settled = true
if (idle) clearTimeout(idle)
if (!result.ok) {
request.abort()
// Close the handle before unlinking (Windows won't delete an open file)
// and never leave a partial/aborted installer lying around in temp.
if (fileStream && !fileStream.destroyed) fileStream.destroy()
unlink(filePath).catch(() => {})
}
resolve(result)
}
// net.request (not fetch) so we can re-validate the host on EVERY redirect
// hop: Gitea 302s a release download to its attachment store, and undici's
// fetch hides the Location header under redirect:'manual', so it can't gate
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
const request = net.request({ url, redirect: 'manual' })
const armIdle = (): void => {
if (idle) clearTimeout(idle)
idle = setTimeout(() => {
finish({ ok: false, error: 'Download stalled — please try again.' })
}, DOWNLOAD_IDLE_TIMEOUT_MS)
}
request.on('redirect', (_status, _method, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
return
}
const lenHeader = response.headers['content-length']
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
const stream = createWriteStream(filePath)
fileStream = stream
// Hash the bytes as they stream by, so verification needs no second pass.
const hash = createHash('sha256')
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume()
// exist at runtime but aren't in its type, so feature-detect them to apply
// backpressure — without it a ~300 MB installer can buffer in memory.
const flow = response as unknown as { pause?(): void; resume?(): void }
let received = 0
armIdle()
response.on('data', (chunk: Buffer) => {
armIdle()
received += chunk.length
hash.update(chunk)
if (!stream.write(chunk) && flow.pause && flow.resume) {
flow.pause()
stream.once('drain', () => flow.resume?.())
}
if (!wc.isDestroyed()) {
const progress: AppUpdateProgress = {
received,
total,
fraction: total ? received / total : undefined
}
wc.send(IpcChannels.appUpdateProgress, progress)
}
})
response.on('end', () => {
// Body fully received — "stalled" is no longer meaningful past this point.
if (idle) clearTimeout(idle)
stream.end(() => {
// A truncated download (connection dropped mid-stream) must never be run.
if (total !== undefined && received !== total) {
finish({ ok: false, error: 'The download was incomplete — please try again.' })
return
}
// Verify the bytes we wrote match the release's published SHA-256.
if (expectedSha && hash.digest('hex') !== expectedSha) {
finish({
ok: false,
error:
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
})
return
}
finish({ ok: true, filePath })
})
})
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
// promise can't hang.
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
request.end()
})
}
/** Let the launched installer spawn before we quit to release our files (ms). */
const INSTALLER_HANDOFF_MS = 1500
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
try {
// Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir,
// never an arbitrary path handed over IPC. Compare the parent directory by
// equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
const expectedDir = normalize(updateDir()).toLowerCase()
const target = normalize(filePath)
if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
return { ok: false, error: 'Refused to run an unexpected file.' }
}
await stat(target) // throws if the file is missing
const err = await shell.openPath(target) // hand the installer to the OS
if (err) return { ok: false, error: err }
// Give the installer a beat to spawn, then quit so it can overwrite our files.
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
+9 -3
View File
@@ -10,17 +10,23 @@
* (Call sites also pass `--` before the positional URL as defence in depth.)
*
* Throws a user-friendly Error on anything that isn't an http(s) URL.
*
* Returns the parser-NORMALISED URL (`u.href`), not the raw input (audit F5).
* The WHATWG URL parser silently tolerates interior tab/newline characters and
* leading C0 control bytes (which `String.trim()` does not strip), so returning
* the raw string could hand a downstream consumer — argv, or the sign-in
* window's loadURL — a value subtly different from the one actually validated.
* Emitting `u.href` guarantees callers use exactly the URL that passed the check.
*/
export function assertHttpUrl(raw: string): string {
const trimmed = (raw ?? '').trim()
let u: URL
try {
u = new URL(trimmed)
u = new URL((raw ?? '').trim())
} catch {
throw new Error('That doesnt look like a valid URL.')
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new Error('Only http and https links are supported.')
}
return trimmed
return u.href
}
+17 -7
View File
@@ -13,15 +13,21 @@ import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem }
/**
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
* `-o`. Reject anything that could write outside that directory — an absolute
* path, or any `..` path segment — so a malicious backup/settings write can't
* traverse out of the chosen folder (e.g. '%(title)s\..\..\win32.exe'). The
* template still legitimately contains yt-dlp `%(field)s` tokens and `/` or `\`
* for sub-folders, which are fine.
* `-o`. Reject anything that could write outside that directory so a malicious
* backup/settings write can't traverse out of the chosen folder (e.g.
* '%(title)s\..\..\win32.exe'). Legitimate templates still contain yt-dlp
* `%(field)s` tokens and `/` or `\` for sub-folders, which are fine.
*
* Two Windows-specific bypasses are guarded beyond the obvious cases (audit T1):
* - drive-relative prefixes like 'C:foo' — `path.isAbsolute` returns FALSE for
* these, yet they escape the output dir, so a leading drive letter is rejected.
* - a '..' segment dressed up with trailing dots/spaces ('.. ', '.. .') —
* Windows silently trims those, so an exact `=== '..'` check would miss them.
*/
const TRAVERSAL_SEGMENT = /^[. ]*\.\.[. ]*$/
export function isSafeFilenameTemplate(template: string): boolean {
if (isAbsolute(template)) return false
return !template.split(/[\\/]/).some((segment) => segment === '..')
if (isAbsolute(template) || /^[a-zA-Z]:/.test(template)) return false
return !template.split(/[\\/]/).some((segment) => TRAVERSAL_SEGMENT.test(segment))
}
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
@@ -88,6 +94,10 @@ export function isValidSource(o: unknown): o is Source {
typeof s.addedAt === 'number' &&
typeof s.itemCount === 'number' &&
isOptionalString(s.channel) &&
// feedUrl drives a network fetch in the sync, so validate its shape here too
// (audit T7); the host is additionally restricted at the fetch boundary.
isOptionalString(s.feedUrl) &&
(s.watched === undefined || typeof s.watched === 'boolean') &&
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
)
}
+85 -3
View File
@@ -1,7 +1,36 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import {
isYtdlpUpdateChannel,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus
} from '@shared/ipc'
/**
* Ensure the writable managed yt-dlp.exe exists, copying the bundled seed in if
* it doesn't (first run, or after the user/AV deleted it). Best-effort and
* idempotent — when the copy is already present this is just one existsSync, so
* it's cheap to call before any spawn/update. Does nothing if the seed itself is
* gone (a broken install); callers surface their own missing-binary error then.
*/
export function ensureManagedYtdlp(): void {
const managed = getYtdlpPath()
if (existsSync(managed)) return
const seed = getBundledYtdlpPath()
if (!existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent */
}
}
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
@@ -38,6 +67,17 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
* a per-user install; it would fail under a locked-down system install.
*/
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
// Validate against the channel allowlist BEFORE the value reaches `--update-to`.
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an
// arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a
// compromised renderer over IPC) must never be forwarded. (audit F1)
if (!isYtdlpUpdateChannel(channel)) {
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
}
// Self-heal: restore the managed copy from the bundled seed before updating, so
// a deleted yt-dlp.exe is re-seeded and then updated in one click.
ensureManagedYtdlp()
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
@@ -65,3 +105,45 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
)
})
}
/**
* Startup yt-dlp maintenance: always seed the managed copy, then — if auto-update
* is on and the daily throttle has elapsed — self-update it to the configured
* channel. Whether it changed is decided by comparing `--version` before/after
* (robust against yt-dlp's localized "up to date" wording). Status is pushed to
* the renderer so the Settings UI reflects a check it didn't trigger.
*
* Best-effort: every failure path still records the attempt time (so an
* unreachable update server doesn't re-hit the network every launch) and never
* throws — a stale binary must never block the app from starting.
*/
export async function runStartupYtdlpAutoUpdate(
send: (status: YtdlpAutoUpdateStatus) => void
): Promise<void> {
ensureManagedYtdlp()
const settings = getSettings()
if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) {
return
}
const channel = settings.ytdlpChannel
send({ phase: 'checking', channel })
const before = await getYtdlpVersion()
const result = await updateYtdlp(channel)
setSettings({ ytdlpLastUpdateCheck: Date.now() })
if (!result.ok) {
send({ phase: 'error', channel, error: result.error, checkedAt: Date.now() })
return
}
const after = await getYtdlpVersion()
const changed = before.ok && after.ok && before.version !== after.version
send({
phase: changed ? 'updated' : 'current',
channel,
version: after.ok ? after.version : undefined,
checkedAt: Date.now()
})
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Pure auto-update scheduling policy for yt-dlp. Kept free of any electron /
* node-runtime dependency (like buildArgs.ts) so the throttle can be unit-tested
* without spinning up Electron. ytdlp.ts imports these to drive the real check.
*/
/** Once-a-day throttle for the startup auto-update check. */
export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000
/**
* Whether a background yt-dlp update check should run now: only when auto-update
* is enabled AND at least one interval has elapsed since the last check. A
* zero/absent lastCheck means "never checked" → run. `now` and the interval are
* passed in so the decision is a pure function of its inputs.
*/
export function shouldAutoCheckYtdlp(
enabled: boolean,
lastCheck: number,
now: number,
intervalMs: number = AUTO_UPDATE_INTERVAL_MS
): boolean {
if (!enabled) return false
if (!lastCheck) return true
return now - lastCheck >= intervalMs
}
+65 -2
View File
@@ -1,9 +1,14 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus,
type FfmpegVersionResult,
type ProbeResult,
type StartDownloadOptions,
type StartDownloadResult,
@@ -23,14 +28,42 @@ import {
type IndexProgress,
type IndexSourceResult,
type SyncResult,
type ScheduledSyncStatus
type ScheduledSyncStatus,
type TerminalEvent,
type TerminalRunResult,
type TaskbarProgress
} from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
const api = {
/** AeroFetch's own version string (e.g. '0.3.1'). */
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
/** Check the configured Gitea repo for a newer AeroFetch release. */
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
/** Download the latest release's installer to a temp file. */
downloadAppUpdate: (url: string): Promise<AppUpdateDownload> =>
ipcRenderer.invoke(IpcChannels.appUpdateDownload, url),
/** Launch a downloaded installer and quit so it can replace the app. */
runAppUpdate: (filePath: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke(IpcChannels.appUpdateRun, filePath),
/** Subscribe to installer download progress. Returns an unsubscribe function. */
onAppUpdateProgress: (cb: (p: AppUpdateProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: AppUpdateProgress): void => cb(p)
ipcRenderer.on(IpcChannels.appUpdateProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
},
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
@@ -39,6 +72,9 @@ const api = {
cancelDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadCancel, 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),
@@ -91,6 +127,13 @@ const api = {
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
/** Subscribe to background yt-dlp auto-update status. Returns an unsubscribe function. */
onYtdlpAutoUpdateStatus: (cb: (s: YtdlpAutoUpdateStatus) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, s: YtdlpAutoUpdateStatus): void => cb(s)
ipcRenderer.on(IpcChannels.ytdlpAutoUpdateStatus, listener)
return () => ipcRenderer.removeListener(IpcChannels.ytdlpAutoUpdateStatus, listener)
},
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
@@ -169,7 +212,27 @@ const api = {
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
ipcRenderer.on(IpcChannels.indexProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
}
},
// --- Built-in yt-dlp terminal (Phase N) ---
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
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) => {
const listener = (_e: IpcRendererEvent, ev: TerminalEvent): void => cb(ev)
ipcRenderer.on(IpcChannels.terminalOutput, listener)
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
},
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */
setTaskbarProgress: (p: TaskbarProgress): void =>
ipcRenderer.send(IpcChannels.taskbarProgress, p)
}
export type Api = typeof api
+84 -6
View File
@@ -1,14 +1,18 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView'
import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useSystemTheme } from './store/systemTheme'
import { useDownloads } from './store/downloads'
import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme'
const useStyles = makeStyles({
provider: {
@@ -32,9 +36,75 @@ function App(): React.JSX.Element {
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads')
const [paletteOpen, setPaletteOpen] = useState(false)
// Ctrl/Cmd+K toggles the command palette.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
setPaletteOpen((o) => !o)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
// directly (not via a selector) so taskbar updates don't re-render the app.
useEffect(() => {
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 })
}
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-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') },
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Wait for the download bar to mount after the tab switch, then focus.
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
}, [])
// Sidebar collapse, persisted across launches in localStorage.
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
)
function toggleCollapsed(): void {
setCollapsed((c) => {
const next = !c
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
return next
})
}
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
@@ -60,17 +130,25 @@ function App(): React.JSX.Element {
<Sidebar
tab={tab}
onTabChange={setTab}
theme={theme}
isDark={isDark}
followingSystem={theme === 'system'}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
onSetTheme={(mode) => updateSettings({ theme: mode })}
version={version}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />}
</main>
{paletteOpen && (
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
)}
</>
)}
</div>
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
export interface PaletteAction {
id: string
label: string
/** short right-aligned hint, e.g. a shortcut or category */
hint?: string
run: () => void
}
const useStyles = makeStyles({
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
},
list: {
maxHeight: '50vh',
overflowY: 'auto',
padding: '6px'
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
width: '100%',
padding: '9px 12px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
itemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
itemHint: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200
},
empty: {
padding: '14px 12px',
color: tokens.colorNeutralForeground3
}
})
/**
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
*/
export function CommandPalette({
actions,
onClose
}: {
actions: PaletteAction[]
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
setSel(0)
}, [q])
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(filtered.length - 1, s + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(0, s - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const a = filtered[sel]
if (a) {
a.run()
onClose()
}
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command palette"
>
<input
ref={inputRef}
className={styles.input}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a command…"
aria-label="Command palette search"
/>
<div className={styles.list}>
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
<button
key={a.id}
type="button"
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => {
a.run()
onClose()
}}
onMouseEnter={() => setSel(i)}
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button>
))
)}
</div>
</div>
</div>
)
}
+293 -288
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef } from 'react'
import {
Input,
Textarea,
Field,
Button,
Checkbox,
Switch,
Field,
Spinner,
Text,
Caption1,
@@ -16,36 +16,23 @@ import {
import {
ArrowDownloadRegular,
ClipboardPasteRegular,
FolderRegular,
SearchRegular,
VideoClipRegular,
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
DismissRegular,
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular,
CodeRegular,
EyeRegular,
EyeOffRegular,
CopyRegular
CutRegular,
CalendarClockRegular,
WarningRegular
} from '@fluentui/react-icons'
import type {
MediaInfo,
FormatOption,
PlaylistInfo,
DownloadOptions,
CommandPreviewResult,
StartDownloadOptions
} from '@shared/ipc'
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings'
import { useTemplates } from '../store/templates'
import { Select } from './Select'
import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
/** A quick heuristic for "this clipboard text is a link worth offering". */
function looksLikeUrl(text: string): boolean {
@@ -59,6 +46,23 @@ function looksLikeUrl(text: string): boolean {
}
}
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
function firstUrl(text: string): string | null {
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (looksLikeUrl(line)) return line
}
return null
}
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
function parseUrlFile(content: string): string | null {
const m = /^\s*URL\s*=\s*(\S+)/im.exec(content)
const url = m?.[1]?.trim()
return url && looksLikeUrl(url) ? url : null
}
const useStyles = makeStyles({
root: {
display: 'flex',
@@ -69,6 +73,11 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4
},
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: {
display: 'flex',
gap: '8px'
@@ -184,33 +193,6 @@ const useStyles = makeStyles({
spacer: {
flexGrow: 1
},
// --- per-download options panel ---
optionsBar: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
optionsPanel: {
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- command preview ---
previewPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewCommandText: {
fontFamily: tokens.fontFamilyMonospace,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
},
// --- playlist selection ---
plPanel: {
display: 'flex',
@@ -241,10 +223,35 @@ const useStyles = makeStyles({
overflowY: 'auto',
paddingRight: '4px'
},
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0'
padding: '2px 0',
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',
@@ -259,61 +266,100 @@ const useStyles = makeStyles({
plItemMeta: {
color: tokens.colorNeutralForeground3
},
folder: {
// --- duplicate warning ---
dupRow: {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: tokens.colorNeutralForeground3,
maxWidth: '360px'
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
folderPath: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-start'
},
optButtons: {
display: 'flex',
gap: '8px'
},
trimPanel: {
alignSelf: 'stretch',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// Native datetime-local input, themed to sit beside the Fluent controls.
dtInput: {
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
padding: '6px 10px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
colorScheme: 'light dark'
}
})
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const addMany = useDownloads((s) => s.addMany)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const downloadOptions = useSettings((s) => s.downloadOptions)
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Per-download options override (null = use the persisted defaults).
const [override, setOverride] = useState<DownloadOptions | null>(null)
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
const [trim, setTrim] = useState('')
// Private mode — sticky like an incognito tab; the download still runs and
// appears in the queue, but its completion is never recorded to history.
const [incognito, setIncognito] = useState(false)
// Optional schedule: a datetime-local value; a future time parks the download.
const [showSchedule, setShowSchedule] = useState(false)
const [scheduleAt, setScheduleAt] = useState('')
// Per-download custom-command override: undefined = defer to the settings
// default (customCommandEnabled + defaultTemplateId); null = explicit
// "None" for just this download; a string = a chosen template id override.
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
const [showCustomCommand, setShowCustomCommand] = useState(false)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
const templates = useTemplates((s) => s.templates)
const effectiveTemplateId =
templateOverride !== undefined
? templateOverride
: customCommandEnabled
? settingsDefaultTemplateId
: null
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
const confirmDup = useRef(false)
function onDragOver(e: React.DragEvent): void {
e.preventDefault()
if (!dragActive) setDragActive(true)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
setDragActive(false)
const dt = e.dataTransfer
const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
if (fromText) {
onUrlChange(fromText)
return
}
// A dropped Windows .url Internet Shortcut — read its URL= line.
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
if (file) {
file
.text()
.then((content) => {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(() => {})
}
}
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
@@ -334,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
// Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing).
@@ -393,7 +442,6 @@ export function DownloadBar(): React.JSX.Element {
setSuggestion(null)
}
const folder = outputDir || 'your Downloads folder'
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]
@@ -405,12 +453,15 @@ export function DownloadBar(): React.JSX.Element {
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
}
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info is stale once the URL changes.
// Any probed info — or a duplicate warning — is stale once the URL changes.
if (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
confirmDup.current = false
}
function onKindChange(next: MediaKind): void {
@@ -468,65 +519,41 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Resolves the per-download custom-command override to the raw extra-args
// string sent to main; undefined defers to the settings default there.
function resolveExtraArgs(): string | undefined {
if (templateOverride === undefined) return undefined
if (templateOverride === null) return ''
return templates.find((t) => t.id === templateOverride)?.args ?? ''
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
// The StartDownloadOptions the current form state would produce — shared by
// the real download() call and the command-preview button so they can never
// drift apart.
function currentStartOptions(): StartDownloadOptions {
const base: StartDownloadOptions = {
id: 'preview',
url: url.trim(),
kind,
quality,
outputDir: outputDir || undefined,
options: override ?? undefined,
extraArgs: resolveExtraArgs()
}
if (usingFormats && selectedFormat) {
return {
...base,
kind: 'video',
quality: selectedFormat.label,
formatId: selectedFormat.id,
formatHasAudio: selectedFormat.hasAudio
}
}
return base
}
async function previewCommand(): Promise<void> {
if (!url.trim() || previewing) return
setPreviewing(true)
setPreviewResult(null)
try {
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
} catch (e) {
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setPreviewing(false)
}
}
async function copyPreview(): Promise<void> {
if (!previewResult?.command) return
try {
await navigator.clipboard.writeText(previewResult.command)
} catch {
/* clipboard blocked — ignore in preview */
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
// Duplicate guard: if this URL is already in the queue (and the user hasn't
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
// Canceled/failed items don't count — re-adding those is a legitimate retry.
if (!confirmDup.current) {
const existing = useDownloads
.getState()
.items.find(
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
setDup(existing.title)
return
}
}
confirmDup.current = false
setDup(null)
const meta = info
? {
title: info.title,
@@ -536,53 +563,68 @@ export function DownloadBar(): React.JSX.Element {
}
: {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta,
trim: trimSpec,
scheduledFor,
format: {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
},
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
}
})
} else {
addFromUrl(trimmed, kind, quality, {
...meta,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
}
setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
clearProbe()
}
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
for (const e of chosen) {
addFromUrl(e.url, kind, quality, {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
// Each entry uses its own kind override; quality falls back to that kind's
// default unless the entry matches the bar's current kind/quality.
addMany(
chosen.map((e) => {
const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
})
)
setUrl('')
clearProbe()
}
return (
<div className={styles.root}>
<div
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
onDragOver={onDragOver}
onDragLeave={() => setDragActive(false)}
onDrop={onDrop}
>
<div className={styles.urlRow}>
<Input
className={styles.url}
input={{ id: 'aerofetch-url' }}
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
@@ -629,6 +671,23 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
{dup && (
<div className={styles.dupRow}>
<WarningRegular />
<Caption1 className={styles.suggestionText}>Already in your queue: {dup}.</Caption1>
<Button size="small" appearance="primary" onClick={downloadAnyway}>
Download anyway
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setDup(null)}
aria-label="Dismiss"
/>
</div>
)}
{probing && (
<div className={styles.statusRow}>
<Spinner size="tiny" />
@@ -679,11 +738,17 @@ export function DownloadBar(): React.JSX.Element {
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allSelected ? 'Select none' : 'Select all'}
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
All audio
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<div key={e.index} className={styles.plItemRow}>
<Checkbox
key={e.index}
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
@@ -700,11 +765,81 @@ export function DownloadBar(): React.JSX.Element {
</span>
}
/>
<Hint
label={
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
}
placement="top"
align="end"
>
<button
type="button"
className={styles.plKindBtn}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
>
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
</button>
</Hint>
</div>
))}
</div>
</div>
)}
{!playlist && (
<div className={styles.trimBlock}>
<div className={styles.optButtons}>
<Button
size="small"
appearance="subtle"
icon={<CutRegular />}
onClick={() => setShowTrim((v) => !v)}
>
{trim.trim() ? 'Trim · on' : 'Trim'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<CalendarClockRegular />}
onClick={() => setShowSchedule((v) => !v)}
>
{scheduleAt ? 'Scheduled' : 'Schedule'}
</Button>
</div>
{showTrim && (
<div className={styles.trimPanel}>
<Field
label="Sections to keep"
hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing."
>
<Textarea
value={trim}
onChange={(_, d) => setTrim(d.value)}
placeholder={'0:30-1:45\n3:00-3:30'}
resize="vertical"
/>
</Field>
</div>
)}
{showSchedule && (
<div className={styles.trimPanel}>
<Field
label="Start at"
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
>
<input
type="datetime-local"
className={styles.dtInput}
value={scheduleAt}
onChange={(e) => setScheduleAt(e.target.value)}
/>
</Field>
</div>
)}
</div>
)}
<div className={styles.controls}>
<div className={styles.control}>
<Caption1>Format</Caption1>
@@ -761,144 +896,14 @@ export function DownloadBar(): React.JSX.Element {
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
onClick={download}
disabled={!url.trim()}
>
Download
{scheduleAt ? 'Schedule' : 'Download'}
</Button>
)}
</div>
<div className={styles.optionsBar}>
{incognito ? <EyeOffRegular /> : <EyeRegular />}
<Switch
checked={incognito}
onChange={(_, d) => setIncognito(d.checked)}
label={incognito ? 'Private — wont be saved to history' : 'Private mode'}
/>
</div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<OptionsRegular />}
iconPosition="before"
onClick={() => setShowOptions((v) => !v)}
>
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{override && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
Reset to defaults
</Button>
</>
)}
</div>
{showOptions && (
<div className={styles.optionsPanel}>
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
</div>
)}
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<CodeRegular />}
iconPosition="before"
onClick={() => setShowCustomCommand((v) => !v)}
>
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{templateOverride !== undefined && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
Reset to default
</Button>
</>
)}
<div className={styles.spacer} />
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
<Button
appearance="subtle"
size="small"
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
iconPosition="before"
onClick={previewCommand}
disabled={!url.trim() || previewing}
>
Preview command
</Button>
</Hint>
</div>
{showCustomCommand && (
<div className={styles.optionsPanel}>
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
<Select
aria-label="Custom command template"
value={effectiveTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
/>
</Field>
{templates.length === 0 && (
<Caption1 className={styles.previewMeta}>
No templates yet add one in Settings Custom commands.
</Caption1>
)}
</div>
)}
{previewResult && (
<div className={styles.previewPanel}>
<div className={styles.plHeader}>
<Caption1 className={styles.plHeaderText}>
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
</Caption1>
{previewResult.ok && (
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
Copy
</Button>
)}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setPreviewResult(null)}
aria-label="Dismiss preview"
/>
</div>
{previewResult.ok ? (
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
) : (
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
{previewResult.error}
</Caption1>
)}
</div>
)}
<div className={styles.folder}>
<FolderRegular />
<Caption1 className={styles.folderPath} title={folder}>
Saving to {folder}
</Caption1>
<Hint label="Change download folder" placement="top" align="start">
<Button size="small" appearance="transparent" onClick={chooseOutputDir}>
Change
</Button>
</Hint>
</div>
</div>
)
}
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Field>
</div>
<Field
label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
>
<Input
value={value.formatSort}
placeholder="res,fps,vcodec:av01"
onChange={(_, d) => setOpt('formatSort', d.value)}
/>
</Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Switch
checked={value.embedSubtitles}
@@ -204,6 +215,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
label={value.embedChapters ? 'On' : 'Off'}
/>
</Field>
<Field
label="Split by chapters"
hint="Also save each chapter as its own file. The full-length file is kept too."
>
<Switch
checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch
checked={value.embedMetadata}
+72 -11
View File
@@ -1,30 +1,61 @@
import {
Subtitle2,
Body1,
Caption1,
Button,
ProgressBar,
makeStyles,
tokens
tokens,
shorthands
} from '@fluentui/react-components'
import { ArrowDownloadRegular } from '@fluentui/react-icons'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
const useStyles = makeStyles({
root: {
// Fill the scroll area so the queue list below can flex to the remaining
// height and scroll internally (it's virtualized), instead of the whole page
// growing to thousands of rows.
display: 'flex',
flexDirection: 'column',
gap: '20px'
gap: '20px',
height: '100%'
},
queueHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
list: {
headerActions: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
gap: '8px'
},
summary: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 14px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
summaryBar: {
flexGrow: 1
},
summaryText: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
whiteSpace: 'nowrap'
},
listScroll: {
flexGrow: 1,
// min-height:0 lets this flex child shrink below its content height so it,
// not the page, owns the scrolling.
minHeight: 0
},
empty: {
display: 'flex',
@@ -41,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
const styles = useStyles()
const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const summary = summarizeQueue(items)
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
@@ -50,14 +83,38 @@ export function DownloadsView(): React.JSX.Element {
<div className={styles.root}>
<DownloadBar />
{summary.active && (
<div className={styles.summary}>
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
<Caption1 className={styles.summaryText}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''}
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
</Caption1>
</div>
)}
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={retryAll}
>
Retry all failed ({summary.failed})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
</div>
{items.length === 0 ? (
<div className={styles.empty}>
@@ -65,11 +122,15 @@ export function DownloadsView(): React.JSX.Element {
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
</div>
) : (
<div className={styles.list}>
{items.map((item) => (
<QueueItem key={item.id} item={item} />
))}
</div>
<VirtualList
items={items}
className={styles.listScroll}
gap={10}
overscan={6}
estimateSize={() => 100}
getKey={(item) => item.id}
renderItem={(item) => <QueueItem item={item} />}
/>
)}
</div>
)
+13 -3
View File
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
},
top: { bottom: 'calc(100% + 6px)' },
bottom: { top: 'calc(100% + 6px)' },
right: { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
left: { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
alignStart: { left: 0 },
alignEnd: { right: 0 }
})
interface HintProps {
label: string
placement?: 'top' | 'bottom'
placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end'
children: React.ReactNode
}
@@ -56,8 +58,16 @@ export function Hint({
aria-hidden
className={mergeClasses(
styles.bubble,
placement === 'bottom' ? styles.bottom : styles.top,
align === 'end' ? styles.alignEnd : styles.alignStart
placement === 'bottom'
? styles.bottom
: placement === 'right'
? styles.right
: placement === 'left'
? styles.left
: styles.top,
// start/end alignment only applies to vertical (top/bottom) placements
(placement === 'top' || placement === 'bottom') &&
(align === 'end' ? styles.alignEnd : styles.alignStart)
)}
>
{label}
+10 -12
View File
@@ -14,8 +14,6 @@ import {
OpenRegular,
FolderRegular,
DeleteRegular,
VideoClipRegular,
MusicNote2Regular,
HistoryRegular,
SearchRegular,
ArrowClockwiseRegular,
@@ -24,9 +22,11 @@ import {
} from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { useSettings } from '../store/settings'
import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { Select } from './Select'
@@ -147,7 +147,7 @@ function formatWhen(ts: number): string {
export function HistoryView(): React.JSX.Element {
const styles = useStyles()
const isDark = useSettings((s) => s.theme === 'dark')
const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
const openFile = useHistory((s) => s.openFile)
@@ -282,7 +282,6 @@ export function HistoryView(): React.JSX.Element {
) : (
<div className={styles.list}>
{filtered.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
{selectMode && (
@@ -292,13 +291,12 @@ export function HistoryView(): React.JSX.Element {
aria-label={`Select ${h.title}`}
/>
)}
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
)}
</div>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
kind={h.kind}
iconSize={22}
/>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
+203 -52
View File
@@ -28,9 +28,12 @@ import {
LibraryRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source } from '@shared/ipc'
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -38,10 +41,24 @@ const PREVIEW = typeof window === 'undefined' || !window.electron
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending'
/**
* A flattened row for the virtualized item list: either a playlist header or a
* single video. Flattening the groups into one list lets a 1000s-video source
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
| { 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'
@@ -132,22 +149,29 @@ const useStyles = makeStyles({
},
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
actionSpacer: { flexGrow: 1 },
group: { display: 'flex', flexDirection: 'column' },
groupHead: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 4px',
color: tokens.colorNeutralForeground2
color: tokens.colorNeutralForeground2,
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
rows: { display: 'flex', flexDirection: 'column' },
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '5px 4px 5px 18px'
},
plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: {
width: '60px',
height: '34px',
...shorthands.borderRadius(tokens.borderRadiusSmall)
},
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
rowTitle: {
whiteSpace: 'nowrap',
@@ -229,7 +253,11 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null)
// Which playlist groups are expanded. Empty = all collapsed (the default), so
// an expanded source shows just its group headers until one is opened.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length
@@ -254,8 +282,12 @@ export function LibraryView(): React.JSX.Element {
if (res.error) setError(res.error)
}
// Reset the selection whenever the expanded source changes.
useEffect(() => setSelected(new Set()), [selectedSourceId])
// Reset the selection (and any batch note) whenever the expanded source changes.
useEffect(() => {
setSelected(new Set())
setBatchNote(null)
setExpandedGroups(new Set())
}, [selectedSourceId])
// Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => {
@@ -266,12 +298,40 @@ export function LibraryView(): React.JSX.Element {
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
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)
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = []
for (const g of groups) {
rows.push({ kind: 'header', title: g.title, items: g.items })
if (groups.length === 1 || expandedGroups.has(g.title)) {
for (const it of g.items) rows.push({ kind: 'item', item: it })
}
}
return rows
}, [groups, expandedGroups])
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
// An item is worth (re)queuing when it isn't already downloaded or in flight:
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
// downloading items keeps "Select all → Download" from enqueuing duplicates,
// since addFromUrl doesn't dedupe by URL.
const actionable = (it: MediaItem): boolean => {
const st = effStatus(it)
return st === 'pending' || st === 'error' || st === 'canceled'
}
const actionableItems = items.filter(actionable)
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
async function onIndex(): Promise<void> {
setError(null)
const u = url.trim()
@@ -290,6 +350,15 @@ export function LibraryView(): React.JSX.Element {
})
}
function toggleGroupExpand(title: string): void {
setExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(title)) next.delete(title)
else next.add(title)
return next
})
}
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
@@ -301,16 +370,34 @@ export function LibraryView(): React.JSX.Element {
})
}
// Select / clear every downloadable item across all groups in this source.
function toggleAll(on: boolean): void {
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
}
// One click queues every chosen item — maxConcurrent gates how many actually
// run, the rest wait in the queue. Selection clears since nothing is held back.
function downloadSelected(): void {
if (!selectedSourceId) return
const chosen = items.filter((it) => selected.has(it.id))
enqueueItems(selectedSourceId, chosen)
if (!selectedSourceId || selectedActionable.length === 0) return
const n = enqueueItems(selectedSourceId, selectedActionable)
setSelected(new Set())
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function downloadPending(): void {
if (!selectedSourceId || pendingItems.length === 0) return
const n = enqueueItems(selectedSourceId, pendingItems)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// Queue every actionable (pending/failed/canceled) video in one playlist group,
// so "download this whole playlist" is a single click.
function downloadGroup(groupItems: MediaItem[]): void {
if (!selectedSourceId) return
enqueueItems(selectedSourceId, pendingItems)
const toQueue = groupItems.filter(actionable)
if (toQueue.length === 0) return
const n = enqueueItems(selectedSourceId, toQueue)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function pillClass(status: ItemStatus): string {
@@ -320,6 +407,83 @@ export function LibraryView(): React.JSX.Element {
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 {
return row.kind === 'header' ? `h:${row.title}` : row.item.id
}
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
return (
<div
className={styles.groupHead}
onClick={() => toggleGroupExpand(row.title)}
role="button"
tabIndex={0}
onKeyDown={(e) =>
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
}
aria-expanded={open}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={(e) => {
e.stopPropagation()
toggleGroup(row.items, !allOn)
}}
>
{allOn ? 'None' : 'All'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<ArrowDownloadRegular />}
disabled={groupActionable === 0}
onClick={(e) => {
e.stopPropagation()
downloadGroup(row.items)
}}
>
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
</Button>
</div>
)
}
const it = row.item
const status = effStatus(it)
return (
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
@@ -411,6 +575,12 @@ export function LibraryView(): React.JSX.Element {
>
<div className={styles.detail}>
<div className={styles.actionRow}>
<Checkbox
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
onChange={(_, d) => toggleAll(!!d.checked)}
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)}
@@ -426,8 +596,9 @@ export function LibraryView(): React.JSX.Element {
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadSelected}
disabled={selectedActionable.length === 0}
>
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
Download {selectedActionable.length} selected
</Button>
</>
) : (
@@ -438,7 +609,7 @@ export function LibraryView(): React.JSX.Element {
onClick={downloadPending}
disabled={pendingItems.length === 0}
>
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
Download {pendingItems.length} pending
</Button>
)}
<span className={styles.switchRow}>
@@ -468,49 +639,29 @@ export function LibraryView(): React.JSX.Element {
</Button>
</div>
{groups.map((g) => {
const allOn = g.items.every((it) => selected.has(it.id))
return (
<div key={g.title} className={styles.group}>
<div className={styles.groupHead}>
<AppsListRegular />
<span className={styles.groupTitle}>{g.title}</span>
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={() => toggleGroup(g.items, !allOn)}
>
{allOn ? 'None' : 'All'}
</Button>
</div>
<div className={styles.rows}>
{g.items.map((it) => {
const status = effStatus(it)
return (
<div key={it.id} className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
// panel so 1000s of rows stay light. A definite height (not
// max-height) gives the virtualizer a viewport to measure.
<VirtualList
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && (
<Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>
) : (
// Small source: render inline so the card grows naturally.
<div className={styles.plainList}>
{flatRows.map((row) => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
</SourceCard>
))}
</div>
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { makeStyles, mergeClasses } from '@fluentui/react-components'
import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import type { MediaKind } from '@shared/ipc'
import { useResolvedDark } from '../store/systemTheme'
import { thumbColors } from '../theme'
const useStyles = makeStyles({
box: {
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
img: {
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block'
}
})
/**
* A media preview thumbnail: shows the image at `src` when one is available and
* loads, otherwise a kind icon on a quiet neutral tint (the same palette the
* placeholders used before). The load failure is tracked per-src, so a thumbnail
* that arrives later (e.g. resolved after a probe) is retried rather than left on
* the fallback. The caller sizes the box via `className` (width/height/radius).
*/
export function MediaThumb({
src,
kind,
iconSize = 24,
className
}: {
src?: string
kind: MediaKind
iconSize?: number
className?: string
}): React.JSX.Element {
const styles = useStyles()
const isDark = useResolvedDark()
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
const [failedSrc, setFailedSrc] = useState<string | null>(null)
const showImg = !!src && failedSrc !== src
// The neutral tint always backs the box, so there's a placeholder behind the
// image while it loads (and the kind icon stays legible when there's no image).
return (
<div
className={mergeClasses(styles.box, className)}
style={{ backgroundColor: colors.bg, color: colors.fg }}
>
{showImg ? (
<img
className={styles.img}
src={src}
alt=""
loading="lazy"
onError={() => setFailedSrc(src ?? null)}
/>
) : kind === 'audio' ? (
<MusicNote2Regular fontSize={iconSize} />
) : (
<VideoClipRegular fontSize={iconSize} />
)}
</div>
)
}
+8 -23
View File
@@ -4,7 +4,6 @@ import {
Body1,
Caption1,
Field,
Input,
Button,
makeStyles,
tokens,
@@ -12,7 +11,6 @@ import {
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
FolderRegular,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
justifyContent: 'center',
fontSize: '26px'
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
folderNote: {
color: tokens.colorNeutralForeground3
},
tips: {
display: 'flex',
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const update = useSettings((s) => s.update)
return (
@@ -118,18 +109,12 @@ export function Onboarding(): React.JSX.Element {
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
<Field label="Download folder">
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
Browse
</Button>
</div>
<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.
</Caption1>
</Field>
<div className={styles.tips}>
+102 -15
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import {
Text,
Caption1,
@@ -15,15 +16,18 @@ import {
OpenRegular,
FolderRegular,
ArrowClockwiseRegular,
VideoClipRegular,
MusicNote2Regular,
ArrowDownloadRegular,
ArrowUpRegular,
BookmarkRegular,
PauseRegular,
PlayRegular,
CheckmarkCircleFilled,
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useSettings } from '../store/settings'
import { thumbColors } from '../theme'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
const useStyles = makeStyles({
@@ -92,6 +96,8 @@ 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' }
@@ -101,11 +107,26 @@ function pct(progress: number): string {
return `${Math.round(progress * 100)}%`
}
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
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 isDark = useSettings((s) => s.theme === 'dark')
const thumb = thumbColors[isDark ? 'dark' : 'light'][item.kind === 'audio' ? 'audio' : 'video']
const cancel = useDownloads((s) => s.cancel)
const pause = useDownloads((s) => s.pause)
const resume = useDownloads((s) => s.resume)
const prioritize = useDownloads((s) => s.prioritize)
const saveForLater = useDownloads((s) => s.saveForLater)
const queueNow = useDownloads((s) => s.queueNow)
const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile)
@@ -124,13 +145,12 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
return (
<div className={styles.root}>
<div className={styles.thumb} style={{ backgroundColor: thumb.bg, color: thumb.fg }}>
{item.kind === 'audio' ? (
<MusicNote2Regular fontSize={28} />
) : (
<VideoClipRegular fontSize={28} />
)}
</div>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
kind={item.kind}
iconSize={28}
/>
<div className={styles.body}>
<div className={styles.titleRow}>
@@ -177,12 +197,79 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
</div>
)}
{item.status === 'paused' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
<Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1>
</div>
)}
{item.status === 'saved' && (
<Caption1 className={styles.stats}>
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
</Caption1>
)}
{item.status === 'error' && item.error && (
<Caption1 className={styles.error}>{item.error}</Caption1>
)}
</div>
<div className={styles.actions}>
{item.status === 'downloading' && (
<Hint label="Pause" placement="top" align="end">
<Button
appearance="subtle"
icon={<PauseRegular />}
onClick={() => pause(item.id)}
aria-label="Pause"
/>
</Hint>
)}
{item.status === 'queued' && (
<>
<Hint label="Download next" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowUpRegular />}
onClick={() => prioritize(item.id)}
aria-label="Download next"
/>
</Hint>
<Hint label="Save for later" placement="top" align="end">
<Button
appearance="subtle"
icon={<BookmarkRegular />}
onClick={() => saveForLater(item.id)}
aria-label="Save for later"
/>
</Hint>
</>
)}
{item.status === 'paused' && (
<Hint label="Resume" placement="top" align="end">
<Button
appearance="subtle"
icon={<PlayRegular />}
onClick={() => resume(item.id)}
aria-label="Resume"
/>
</Hint>
)}
{item.status === 'saved' && (
<Hint label="Add to queue" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowDownloadRegular />}
onClick={() => queueNow(item.id)}
aria-label="Add to queue"
/>
</Hint>
)}
{active && (
<Hint label="Cancel" placement="top" align="end">
<Button
@@ -239,4 +326,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
</div>
</div>
)
}
})
+314 -20
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Field,
Input,
@@ -10,6 +10,7 @@ import {
Caption1,
Spinner,
Text,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
@@ -31,13 +32,18 @@ import {
CopyRegular,
DeleteRegular,
PaintBucketRegular,
AccessibilityRegular
AccessibilityRegular,
ArrowSyncRegular,
OpenRegular,
SearchRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult,
type AppUpdateInfo,
type MediaKind,
type CookieSource,
type CookieBrowser,
@@ -71,6 +77,17 @@ const useStyles = makeStyles({
padding: '20px',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
notesBox: {
maxHeight: '180px',
overflowY: 'auto',
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`,
whiteSpace: 'pre-wrap',
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300
},
sectionHeader: {
display: 'flex',
alignItems: 'center',
@@ -170,8 +187,32 @@ const UPDATE_CHANNEL_OPTIONS = [
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
// 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.
// The cards are the root's direct children; the search box (marked
// data-settings-search) is skipped. React never sets `style` on the cards, so
// our inline display survives their re-renders.
const [search, setSearch] = useState('')
const [noResults, setNoResults] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const q = search.trim().toLowerCase()
let shown = 0
for (const el of Array.from(root.children) as HTMLElement[]) {
if (el.dataset.settingsSearch !== undefined) continue
const match = !q || (el.textContent ?? '').toLowerCase().includes(q)
el.style.display = match ? '' : 'none'
if (match) shown++
}
setNoResults(q !== '' && shown === 0)
}, [search])
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
const clearDir = useSettings((s) => s.clearDir)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
@@ -185,6 +226,8 @@ export function SettingsView(): React.JSX.Element {
const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c)
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
@@ -192,6 +235,10 @@ export function SettingsView(): React.JSX.Element {
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
const errorEntries = useErrorLog((s) => s.entries)
@@ -202,11 +249,19 @@ 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 [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
// --- AeroFetch app self-update ---
const [appVersion, setAppVersion] = useState('')
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
const [appChecking, setAppChecking] = useState(false)
const [appDownloading, setAppDownloading] = useState(false)
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
const [appUpdError, setAppUpdError] = useState<string | null>(null)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
@@ -221,6 +276,71 @@ export function SettingsView(): React.JSX.Element {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
useEffect(() => {
window.api.getAppVersion().then(setAppVersion).catch(() => {})
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(() => {})
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(() => {})
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkAppUpdate(): Promise<void> {
setAppChecking(true)
setAppUpd(null)
setAppUpdError(null)
try {
const info = await window.api.checkForAppUpdate()
// Funnel a failed check into the single error slot rather than storing a
// second not-ok AppUpdateInfo, so only one error message can ever render.
if (info.ok) setAppUpd(info)
else setAppUpdError(info.error ?? 'Update check failed.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppChecking(false)
}
}
async function installAppUpdate(): Promise<void> {
if (!appUpd?.downloadUrl) return
setAppDownloading(true)
setAppFraction(undefined)
setAppUpdError(null)
try {
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
if (!dl.ok || !dl.filePath) {
setAppUpdError(dl.error ?? 'Download failed.')
return
}
const run = await window.api.runAppUpdate(dl.filePath)
// On success the app quits as the installer launches; only errors return here.
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppDownloading(false)
}
}
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
@@ -310,7 +430,7 @@ export function SettingsView(): React.JSX.Element {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(updateChannel)
const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
} catch (e) {
@@ -321,24 +441,71 @@ export function SettingsView(): React.JSX.Element {
}
return (
<div className={styles.root}>
<div className={styles.root} ref={rootRef}>
<div data-settings-search>
<Input
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
contentBefore={<SearchRegular />}
style={{ width: '100%' }}
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match {search}.
</Caption1>
)}
</div>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2>Downloads</Subtitle2>
</div>
<Field label="Download folder">
<Field
label="Video folder"
hint="Where video downloads are saved. Leave blank to use Documents\Video."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
value={videoDir}
placeholder="Documents\Video (default)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Browse
</Button>
{videoDir && (
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field
label="Audio folder"
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={audioDir}
placeholder="Documents\Audio (default)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Browse
</Button>
{audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
Reset
</Button>
)}
</div>
</Field>
@@ -391,6 +558,17 @@ export function SettingsView(): React.JSX.Element {
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
<Field
label="Keep running in the tray"
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting. Use the tray icon to reopen or quit."
>
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
@@ -499,6 +677,28 @@ export function SettingsView(): React.JSX.Element {
label={useAria2c ? 'On' : 'Off'}
/>
</Field>
<Field
label="YouTube client (advanced)"
hint="Override the YouTube extraction client when downloads start failing the bot check. Try web_safari, tv, or mweb. Leave blank for yt-dlp's default."
>
<Input
value={youtubePlayerClient}
placeholder="web_safari"
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
/>
</Field>
<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."
>
<Input
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => update({ youtubePoToken: d.value })}
/>
</Field>
</Card>
<Card className={styles.card}>
@@ -731,20 +931,90 @@ export function SettingsView(): React.JSX.Element {
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2>Software update</Subtitle2>
</div>
<Caption1 className={styles.hint}>
{appVersion
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
: 'Checking your AeroFetch version…'}
</Caption1>
<div className={styles.folderRow}>
<Button
icon={<ArrowSyncRegular />}
onClick={checkAppUpdate}
disabled={appChecking || appDownloading}
>
{appChecking ? 'Checking…' : 'Check for updates'}
</Button>
{appChecking && <Spinner size="tiny" />}
</div>
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
Version {appUpd.latestVersion} is available here&apos;s what changed:
</Text>
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
<div className={styles.folderRow}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={installAppUpdate}
disabled={appDownloading || !appUpd.downloadUrl}
>
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
View release
</Button>
)}
</div>
{!appUpd.downloadUrl && (
<Caption1 className={styles.hint}>
This release has no installer attached use View release to download it manually.
</Caption1>
)}
{appDownloading && <ProgressBar value={appFraction} />}
</>
)}
{appUpd?.ok && !appUpd.available && (
<Text>You&apos;re up to date v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpdError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{appUpdError}
</Caption1>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg.
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>
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<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."
>
<Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/>
</Field>
{version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
@@ -753,13 +1023,37 @@ export function SettingsView(): React.JSX.Element {
{version.error}
</Caption1>
)}
{ffmpeg && (
<>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
</Text>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
ffprobe {ffmpeg.ffprobe ?? 'not found'}
</Text>
</>
)}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
</Caption1>
)}
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<Field label="Update channel" hint="Nightly tracks yt-dlp's daily build; stable is the tagged release.">
<Field
label="Update channel"
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
>
<Select
aria-label="Update channel"
value={updateChannel}
value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/>
</Field>
<div className={styles.folderRow}>
+185 -39
View File
@@ -1,22 +1,21 @@
import {
Caption1,
Switch,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
HistoryRegular,
LibraryRegular,
WindowConsoleRegular,
SettingsRegular,
WeatherMoonRegular,
WeatherSunnyRegular
WeatherSunnyRegular,
DesktopRegular,
PanelLeftContractRegular,
PanelLeftExpandRegular
} from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
const useStyles = makeStyles({
root: {
@@ -27,13 +26,48 @@ const useStyles = makeStyles({
gap: '4px',
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
transition: 'width 0.15s ease'
},
rootCollapsed: {
width: '60px',
alignItems: 'center'
},
topBar: {
display: 'flex',
justifyContent: 'flex-end',
paddingBottom: '2px'
},
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',
gap: '11px',
padding: '6px 10px 14px'
padding: '0 10px 14px'
},
brandCollapsed: {
padding: '0 0 12px',
justifyContent: 'center'
},
mark: {
width: '36px',
@@ -64,7 +98,8 @@ const useStyles = makeStyles({
nav: {
display: 'flex',
flexDirection: 'column',
gap: '3px'
gap: '3px',
alignSelf: 'stretch'
},
navItem: {
display: 'flex',
@@ -84,6 +119,10 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
navItemCollapsed: {
justifyContent: 'center',
padding: '9px 0'
},
navItemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
@@ -94,22 +133,46 @@ const useStyles = makeStyles({
},
navIcon: {
fontSize: '18px',
flexShrink: 0
flexShrink: 0,
display: 'flex'
},
spacer: {
flexGrow: 1
},
themeRow: {
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
themeGroup: {
alignSelf: 'stretch',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
color: tokens.colorNeutralForeground3
width: '100%',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
themeLabel: {
themeSeg: {
flex: 1,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
gap: '9px'
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
}
}
})
@@ -117,70 +180,153 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
]
const THEMES: { value: ThemeMode; label: string; icon: React.JSX.Element }[] = [
{ value: 'light', label: 'Light', icon: <WeatherSunnyRegular /> },
{ value: 'dark', label: 'Dark', icon: <WeatherMoonRegular /> },
{ value: 'system', label: 'Auto', icon: <DesktopRegular /> }
]
interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
/** the current theme preference: explicit light/dark, or 'system' to follow the OS */
theme: ThemeMode
/** whether the resolved theme is currently dark (drives the collapsed toggle icon) */
isDark: boolean
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
followingSystem: boolean
onToggleTheme: () => void
onSetTheme: (mode: ThemeMode) => void
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
version: string
collapsed: boolean
onToggleCollapsed: () => void
}
export function Sidebar({
tab,
onTabChange,
theme,
isDark,
followingSystem,
onToggleTheme
onSetTheme,
version,
collapsed,
onToggleCollapsed
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
// 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 themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={18} />
) : isDark ? (
<WeatherMoonRegular fontSize={18} />
) : (
<WeatherSunnyRegular fontSize={18} />
)
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
return (
<nav className={styles.root}>
<div className={styles.brand}>
<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}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
>
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
</Hint>
</div>
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
</div>
)}
</div>
<div className={styles.nav}>
{NAV.map((n) => {
const active = tab === n.value
return (
const btn = (
<button
key={n.value}
type="button"
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive
)}
style={
active
active && !collapsed
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined
}
onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined}
aria-label={n.label}
>
<span className={styles.navIcon}>{n.icon}</span>
{n.label}
{!collapsed && n.label}
</button>
)
return collapsed ? (
<Hint key={n.value} label={n.label} placement="right">
{btn}
</Hint>
) : (
btn
)
})}
</div>
<div className={styles.spacer} />
<div className={styles.themeRow}>
<span className={styles.themeLabel}>
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
</span>
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
{collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<button
type="button"
className={styles.iconBtn}
onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`}
>
{themeIcon}
</button>
</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>
)}
</nav>
)
}
@@ -74,8 +74,10 @@ interface Draft {
id: string | null
name: string
args: string
/** optional regex; auto-applies the template to matching URLs */
urlPattern: string
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
@@ -95,14 +97,20 @@ export function TemplateManager(): React.JSX.Element {
const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args })
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
}
function commit(): void {
if (!draft) return
const name = draft.name.trim()
if (!name) return
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId(),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
})
setDraft(null)
}
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/>
<Input
value={draft.urlPattern}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
/>
<div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Subtitle2,
Body1,
Caption1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
text: string
kind: LineKind
}
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,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
buttons: { display: 'flex', gap: '8px' },
log: {
flexGrow: 1,
minHeight: '160px',
overflowY: 'auto',
margin: 0,
padding: '12px 14px',
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 },
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
* flag (enforced again in main see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
const logRef = useRef<HTMLPreElement>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
} else if (ev.type === 'error') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
setRunning(false)
runId.current = null
} else {
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
setRunning(false)
runId.current = null
}
}),
[]
)
// Keep the log pinned to the latest output.
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [lines])
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId()
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
const lineClass = (kind: LineKind): string | 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>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on Run custom commands in Settings
Custom commands to use it.
</Body1>
)}
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
>
Run
</Button>
)}
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setLines([])}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre className={styles.log} ref={logRef}>
{lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span>
) : (
lines.map((l, i) => (
<div key={i} className={lineClass(l.kind)}>
{l.text}
</div>
))
)}
</pre>
</div>
)
}
@@ -0,0 +1,70 @@
import { useRef, type CSSProperties, type ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
/**
* Windowed list: renders only the rows near the viewport, so a list thousands of
* items long stays light one DOM subtree per visible row, not per item. This
* keeps the Downloads queue and a big channel's Library list responsive when a
* source has 1000s of videos.
*
* It owns its own scroll container, so size that container via `style`/`className`
* (e.g. `flex: 1` + `minHeight: 0`, or a `maxHeight`). Owning the scroller means
* the virtualizer anchors at scroll offset 0 and never has to track where the
* list sits within the page robust even though the app shares one outer
* scroll area with content above each list.
*
* Row heights are measured (measureElement), so variable-height rows are fine;
* `estimateSize` only needs to be a rough first guess before measurement.
*/
export function VirtualList<T>({
items,
estimateSize,
getKey,
renderItem,
overscan = 8,
gap = 0,
className,
style
}: {
items: T[]
estimateSize: (index: number) => number
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => ReactNode
overscan?: number
gap?: number
className?: string
style?: CSSProperties
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize,
overscan,
gap,
getItemKey: (index) => getKey(items[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>
))}
</div>
</div>
)
}
+41 -5
View File
@@ -11,7 +11,8 @@ import App from './App'
// In the real Electron app this branch is skipped — preload provides window.api.
if (import.meta.env.DEV && !window.api) {
const MOCK_SETTINGS: Settings = {
outputDir: 'C:\\Users\\you\\Downloads',
videoDir: 'C:\\Users\\you\\Documents\\Video',
audioDir: 'C:\\Users\\you\\Documents\\Audio',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -26,13 +27,19 @@ if (import.meta.env.DEV && !window.api) {
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: true
hasCompletedOnboarding: true,
minimizeToTray: false
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
@@ -44,7 +51,29 @@ if (import.meta.env.DEV && !window.api) {
]
window.api = {
getAppVersion: async () => '0.4.0-preview',
checkForAppUpdate: async () => {
await new Promise((r) => setTimeout(r, 400))
return {
ok: true,
available: true,
currentVersion: '0.4.0',
latestVersion: '0.5.0',
notes:
'### Whats new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
assetName: 'AeroFetch-Setup-0.5.0.exe'
}
},
downloadAppUpdate: async () => ({
ok: false,
error: 'Downloading updates is disabled in the browser preview.'
}),
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)' }),
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.
@@ -87,6 +116,7 @@ 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 () => '',
@@ -121,17 +151,19 @@ if (import.meta.env.DEV && !window.api) {
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
return {
ok: true,
command:
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
`${extra} -- ${opts.url}`
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
}
},
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)` }
},
// 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' }),
@@ -191,7 +223,11 @@ if (import.meta.env.DEV && !window.api) {
durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2
})),
onIndexProgress: () => () => {}
onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {},
setTaskbarProgress: () => {}
}
}
+208 -45
View File
@@ -6,7 +6,14 @@ import { useSources } from './sources'
export type MediaKind = 'video' | 'audio'
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled'
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'saved'
| 'completed'
| 'error'
| 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
@@ -39,6 +46,10 @@ export interface DownloadItem {
options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string
/** raw trim spec — time ranges to keep (parsed to --download-sections in main) */
trim?: string
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
scheduledFor?: number
/** private download — completion is never recorded to history */
incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
@@ -54,17 +65,8 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
}
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState {
items: DownloadItem[]
addFromUrl: (
url: string,
kind: MediaKind,
quality: string,
opts?: {
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
format?: ChosenFormat
title?: string
channel?: string
@@ -72,12 +74,50 @@ interface DownloadState {
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
trim?: string
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
scheduledFor?: number
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
) => void
}
/** One URL to enqueue in a single addMany batch. */
export interface AddEntry {
url: string
kind: MediaKind
quality: string
opts?: AddOptions
}
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState {
items: DownloadItem[]
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
/**
* Enqueue many URLs at once with a single state update and a single pump, so
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
* way looping addFromUrl would.
*/
addMany: (entries: AddEntry[]) => void
retry: (id: string) => void
/** re-queue every failed item at once (Phase M) */
retryAll: () => void
/** stop a running download but keep its partial file for a later resume (Phase M) */
pause: (id: string) => void
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
resume: (id: string) => void
/** move a queued item to the front of the promotion order ("download next", Phase M) */
prioritize: (id: string) => void
/** park a queued item indefinitely as 'saved' (Phase M) */
saveForLater: (id: string) => void
/** un-park a 'saved' item back into the queue now (clears any schedule) */
queueNow: (id: string) => void
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void
cancel: (id: string) => void
remove: (id: string) => void
clearFinished: () => void
@@ -95,6 +135,44 @@ function newId(): string {
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
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
*/
function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOptions): DownloadItem {
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
// A future scheduled time parks the item as 'saved' until its moment arrives;
// a past/absent time starts it queued as usual.
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId(),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: scheduledFor ? 'saved' : 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
trim: opts?.trim,
scheduledFor,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
}
function titleFromUrl(url: string): string {
try {
const u = new URL(url)
@@ -247,11 +325,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
url: item.url,
kind: item.kind,
quality: item.quality,
outputDir: useSettings.getState().outputDir || undefined,
// No outputDir here: main routes each download into the user's per-kind
// folder (Settings → Video/Audio folder), or the Documents\… default.
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
options: item.options,
extraArgs: item.extraArgs,
trim: item.trim,
meta: item.probedMeta,
collection: item.collection
})
@@ -290,35 +370,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump,
addFromUrl: (url, kind, quality, opts) => {
const id = newId()
// If the caller already probed the URL, carry that metadata so main can skip
// its own redundant probe (audit P2). Only set it when something real was
// provided — a bare paste with no probe leaves it undefined.
const probedMeta: DownloadMeta | undefined =
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
const item: DownloadItem = {
id,
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
set((s) => ({ items: [item, ...s.items] }))
set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
pump()
},
addMany: (entries) => {
if (entries.length === 0) return
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
set((s) => ({ items: [...built, ...s.items] }))
pump()
},
@@ -333,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump()
},
retryAll: () => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
: i
)
}))
pump()
},
pause: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
// Only a running download has a live process to stop; a queued item is
// just parked in place. Either way it becomes 'paused' (pump ignores it).
if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
set((s) => ({
items: s.items.map((i) =>
i.id === id && (i.status === 'downloading' || i.status === 'queued')
? { ...i, status: 'paused', speed: undefined, eta: undefined }
: i
)
}))
pump() // pausing a running item frees a slot
},
resume: (id) => {
// Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
// launchItem re-spawns yt-dlp, which continues the partial file by default.
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
)
}))
pump()
},
prioritize: (id) => {
// Reposition the item so pump() — which promotes the highest-index queued
// 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 {}
const items = s.items.slice()
const [it] = items.splice(idx, 1)
let after = -1
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
items.splice(after + 1, 0, it)
return { items }
})
pump()
},
saveForLater: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'queued'
? { ...i, status: 'saved', scheduledFor: undefined }
: i
)
}))
},
queueNow: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'saved'
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
promoteDueScheduled: () => {
const now = Date.now()
set((s) => ({
items: s.items.map((i) =>
i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
cancel: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
@@ -355,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
clearFinished: () =>
set((s) => ({
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued')
items: s.items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
)
})),
openFile: (id) => {
@@ -437,6 +589,17 @@ export const useDownloads = create<DownloadState>((set, get) => {
}
})
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
st.promoteDueScheduled()
}
}, 15_000)
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) {
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
+139
View File
@@ -0,0 +1,139 @@
/**
* Pure helpers for the Downloads view (Phase M): a one-line aggregate of the live
* queue, and same-video detection for the duplicate guard. Kept free of any store
* / window dependency (only a type-only import of DownloadItem) so it can be
* unit-tested without constructing the Zustand store or its preview ticker.
*/
import type { DownloadItem } from './downloads'
// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant
// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024
// difference is immaterial for a live display estimate.
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])
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)
}
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec <= 0) return ''
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let v = bytesPerSec
let i = 0
while (v >= 1000 && i < units.length - 1) {
v /= 1000
i++
}
return `${v.toFixed(1)} ${units[i]}`
}
// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds.
function parseEtaSeconds(s?: string): number {
if (!s) return 0
const parts = s.split(':').map((p) => Number(p))
if (parts.length === 0 || parts.some((p) => !isFinite(p))) return 0
return parts.reduce((acc, p) => acc * 60 + p, 0)
}
function formatEta(totalSeconds: number): string {
if (totalSeconds <= 0) return ''
const s = Math.round(totalSeconds)
const m = Math.floor(s / 60)
return `${m}:${String(s % 60).padStart(2, '0')}`
}
export interface QueueSummary {
downloading: number
queued: number
failed: number
/** true when something is downloading or waiting */
active: boolean
/** 0..1, averaged over downloading + queued items */
progress: number
/** combined download speed, formatted; '' when nothing is downloading */
speedLabel: string
/** rough time remaining (the longest active ETA), formatted; '' when unknown */
etaLabel: string
}
/**
* Aggregate the live queue into a one-line summary for the Downloads header.
* Combined speed is summed across downloading items; the "time left" is the
* longest single ETA a reasonable proxy for when the current batch finishes
* (we don't track remaining bytes, so a true combined ETA isn't available).
* Queued items count toward the progress average as 0%, so the bar reflects
* "how much of the visible work is done", not just the active downloads.
*/
export function summarizeQueue(items: DownloadItem[]): QueueSummary {
let downloading = 0
let queued = 0
let failed = 0
let progressSum = 0
let progressCount = 0
let speed = 0
let maxEta = 0
for (const i of items) {
if (i.status === 'downloading') {
downloading++
progressSum += i.progress
progressCount++
speed += parseSpeed(i.speed)
maxEta = Math.max(maxEta, parseEtaSeconds(i.eta))
} else if (i.status === 'queued') {
queued++
progressCount++
} else if (i.status === 'error') {
failed++
}
}
return {
downloading,
queued,
failed,
active: downloading > 0 || queued > 0,
progress: progressCount > 0 ? progressSum / progressCount : 0,
speedLabel: formatSpeed(speed),
etaLabel: formatEta(maxEta)
}
}
// --- Duplicate detection ----------------------------------------------------
function youtubeId(url: string): string | null {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '')
if (host === 'youtu.be') return u.pathname.slice(1) || null
if (host.endsWith('youtube.com')) return u.searchParams.get('v')
} catch {
/* not a URL */
}
return null
}
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '').toLowerCase()
return `${u.protocol}//${host}${u.pathname.replace(/\/$/, '')}${u.search}`
} catch {
return url.trim()
}
}
/**
* True when two URLs point at the same video: a matching YouTube id when both
* are YouTube links, otherwise normalised-URL equality (host lower-cased, `www.`
* and a trailing slash stripped). Powers the "already in your queue" guard.
*/
export function sameVideo(a: string, b: string): boolean {
if (a === b) return true
const ya = youtubeId(a)
const yb = youtubeId(b)
if (ya && yb) return ya === yb
return normalizeUrl(a) === normalizeUrl(b)
}
+18 -6
View File
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
const PREVIEW = typeof window === 'undefined' || !window.electron
const FALLBACK: Settings = {
outputDir: PREVIEW ? 'C:\\Users\\you\\Downloads' : '',
videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '',
audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
@@ -20,22 +21,31 @@ const FALLBACK: Settings = {
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
// 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
hasCompletedOnboarding: PREVIEW,
minimizeToTray: false
}
interface SettingsState extends Settings {
/** true once persisted settings have loaded (always true in preview) */
loaded: boolean
update: (partial: Partial<Settings>) => void
chooseOutputDir: () => void
/** open the OS folder picker and store the result as the video or audio folder */
chooseDir: (target: 'videoDir' | 'audioDir') => void
/** clear a per-kind folder override, restoring its Documents\… default */
clearDir: (target: 'videoDir' | 'audioDir') => void
}
export const useSettings = create<SettingsState>((set, get) => ({
@@ -47,12 +57,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
},
chooseOutputDir: () => {
chooseDir: (target) => {
if (PREVIEW) return
window.api.chooseFolder().then((dir) => {
if (dir) get().update({ outputDir: dir })
if (dir) get().update({ [target]: dir })
})
}
},
clearDir: (target) => get().update({ [target]: '' })
}))
// Load persisted settings on startup.
+14 -13
View File
@@ -6,11 +6,6 @@ import { useSettings } from './settings'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Cap on how many items one "Download" click enqueues, so an entire channel
// can't flood the live queue at once — the rest stay pending for a follow-up
// click. (The automatic incremental feeder is Phase I; see ROADMAP-PINCHFLAT.md.)
export const MAX_ENQUEUE_BATCH = 100
// --- Preview seed data ------------------------------------------------------
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
@@ -87,8 +82,10 @@ interface SourcesState {
reindexSource: (id: string) => Promise<void>
removeSource: (id: string) => void
/**
* Enqueue the given media items into the download queue with folder context,
* capped at MAX_ENQUEUE_BATCH. Returns how many were actually enqueued.
* Enqueue the given media items into the download queue with folder context.
* Queues all of them one click can fill the queue with an entire channel,
* which maxConcurrent then gates into download slots. Returns how many were
* enqueued.
*/
enqueueItems: (sourceId: string, items: MediaItem[]) => number
/** mark an item downloaded once its queue download completes (called by the downloads store) */
@@ -185,10 +182,13 @@ export const useSources = create<SourcesState>((set, get) => ({
const settings = useSettings.getState()
const kind = settings.defaultKind
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
const batch = items.slice(0, MAX_ENQUEUE_BATCH)
const add = useDownloads.getState().addFromUrl
for (const it of batch) {
add(it.url, kind, quality, {
// One batched state update + pump, so queuing a whole channel stays O(n).
useDownloads.getState().addMany(
items.map((it) => ({
url: it.url,
kind,
quality,
opts: {
title: it.title,
channel: src?.channel,
durationLabel: it.durationLabel,
@@ -198,9 +198,10 @@ export const useSources = create<SourcesState>((set, get) => ({
index: it.playlistIndex
},
mediaItemId: it.id
})
}
return batch.length
}))
)
return items.length
},
markDownloaded: (itemId, filePath) => {
+14
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { SystemThemeInfo } from '@shared/ipc'
import { useSettings } from './settings'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -24,3 +25,16 @@ if (!PREVIEW) {
useSystemTheme.setState({ shouldUseDarkColors: e.matches })
)
}
/**
* Whether the app is currently rendering in dark mode. Resolves the 'system'
* preference against the live OS signal, so a component never has to special-case
* it (the bug where a raw `theme === 'dark'` check left thumbnails light under
* Auto + a dark OS). The single source of truth for light/dark used by App for
* the FluentProvider and by anything that needs theme-aware colors (thumbColors).
*/
export function useResolvedDark(): boolean {
const theme = useSettings((s) => s.theme)
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
return theme === 'system' ? systemPrefersDark : theme === 'dark'
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Thumbnail helpers. A media "preview" thumbnail is either the explicit image URL
* yt-dlp handed us at probe time, or for a YouTube video one derived from the
* video id with no extra network probe. Anything non-YouTube without a probed
* thumbnail returns undefined, and the UI falls back to a kind icon.
*/
/**
* Pull the video id out of any YouTube watch / shorts / embed / live / youtu.be
* URL. Returns null for non-YouTube URLs or anything unparseable, so callers can
* cheaply ask "is there a thumbnail derivable from this link?".
*/
export function youtubeId(url: string | undefined): string | null {
if (!url) return null
let u: URL
try {
u = new URL(url)
} catch {
return null
}
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
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]
}
return null
}
/**
* Resolve a preview thumbnail URL for a media item. Prefers an explicit thumbnail
* (from a probe), then a known videoId, then a YouTube URL it can derive an id
* from. Returns undefined when nothing usable is available.
*/
export function thumbUrl(opts: {
thumbnail?: string
videoId?: string
url?: string
}): string | undefined {
if (opts.thumbnail) return opts.thumbnail
const id = opts.videoId || youtubeId(opts.url)
// mqdefault is 320×180 (16:9, no letterbox bars) and exists for every public
// video — a better fit for our 16:9 thumb boxes than the 4:3 hqdefault.
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/mqdefault.jpg` : undefined
}
+182 -5
View File
@@ -4,10 +4,22 @@
*/
export const IpcChannels = {
/** the AeroFetch app version (package.json / app.getVersion) */
appVersion: 'app:version',
/** check the configured Gitea repo for a newer AeroFetch release */
appUpdateCheck: 'app:update-check',
/** download the latest release's installer to a temp file */
appUpdateDownload: 'app:update-download',
/** launch a downloaded installer and quit so it can replace the app */
appUpdateRun: 'app:update-run',
/** main → renderer push channel for installer download progress */
appUpdateProgress: 'app:update-progress',
ytdlpVersion: 'ytdlp:version',
ffmpegVersion: 'ffmpeg:version',
probe: 'media:probe',
downloadStart: 'download:start',
downloadCancel: 'download:cancel',
downloadPause: 'download:pause',
defaultFolder: 'download:default-folder',
chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path',
@@ -30,6 +42,8 @@ export const IpcChannels = {
templatesRemove: 'templates:remove',
commandPreview: 'command:preview',
ytdlpUpdate: 'ytdlp:update',
/** main → renderer push channel for background yt-dlp auto-update status */
ytdlpAutoUpdateStatus: 'ytdlp:auto-update-status',
errorLogList: 'errorlog:list',
errorLogClear: 'errorlog:clear',
backupExport: 'backup:export',
@@ -57,7 +71,14 @@ export const IpcChannels = {
scheduledSyncGet: 'sources:scheduled-sync-get',
scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress'
indexProgress: 'sources:index-progress',
// --- Built-in yt-dlp terminal (Phase N) ---
terminalRun: 'terminal:run',
terminalCancel: 'terminal:cancel',
/** 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'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -133,6 +154,13 @@ export interface DownloadOptions {
videoContainer: VideoContainer
/** preferred video codec (sort tiebreaker, not a hard filter) */
preferredVideoCodec: VideoCodecPref
/**
* Advanced: a raw yt-dlp `-S` format-sort string (e.g. 'res:1080,vcodec:av01,size').
* When non-empty it replaces the codec-derived sort, letting power users rank
* resolution / codec / container / size by weighted priority. Empty = use the
* preferredVideoCodec tiebreaker.
*/
formatSort: string
/** download + embed subtitles into video */
embedSubtitles: boolean
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
@@ -145,6 +173,8 @@ export interface DownloadOptions {
sponsorBlockCategories: SponsorBlockCategory[]
/** embed chapter markers */
embedChapters: boolean
/** split the download into one file per chapter (--split-chapters) */
splitChapters: boolean
/** embed metadata (title/artist/etc.) */
embedMetadata: boolean
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
@@ -163,6 +193,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
audioFormat: 'mp3',
videoContainer: 'mp4',
preferredVideoCodec: 'any',
formatSort: '',
embedSubtitles: false,
subtitleLanguages: 'en',
autoSubtitles: false,
@@ -170,6 +201,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
sponsorBlockMode: 'remove',
sponsorBlockCategories: ['sponsor'],
embedChapters: false,
splitChapters: false,
embedMetadata: true,
embedThumbnail: true,
cropThumbnail: false,
@@ -186,8 +218,74 @@ export interface YtdlpVersionResult {
error?: string
}
/** yt-dlp's self-update release channel (`--update-to <channel>`). */
export type YtdlpUpdateChannel = 'stable' | 'nightly'
/**
* Versions of the bundled ffmpeg + ffprobe, for display in Settings. Each is the
* parsed version token, or null when that binary is missing or unreadable. These
* ship with AeroFetch and aren't auto-updated (ffmpeg has no self-update and,
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here
* just "what's installed".
*/
export interface FfmpegVersionResult {
ffmpeg: string | null
ffprobe: string | null
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
export interface AppUpdateInfo {
ok: boolean
/** true when latestVersion is newer than the running app */
available: boolean
/** the running app's version, e.g. '0.4.0' */
currentVersion: string
/** the latest release's version (tag without a leading 'v'), when ok */
latestVersion?: string
/** the release notes / changelog body (Markdown), shown before updating */
notes?: string
/** the release's web page on Gitea (for "View release") */
htmlUrl?: string
/** direct download URL of the Windows installer asset, when the release has one */
downloadUrl?: string
/** the installer asset's file name */
assetName?: string
/** human-readable error, when ok is false */
error?: string
}
/** Result of downloading the update installer to a temp file. */
export interface AppUpdateDownload {
ok: boolean
/** absolute path to the downloaded installer, when ok */
filePath?: string
error?: string
}
/** Live progress of the installer download (main → renderer). */
export interface AppUpdateProgress {
/** bytes downloaded so far */
received: number
/** total bytes, when the server reports Content-Length */
total?: number
/** 0..1 fraction, when total is known */
fraction?: number
}
/**
* yt-dlp's self-update release channels (`--update-to <channel>`).
*
* SECURITY (audit F1): `--update-to` also accepts an `OWNER/REPO@TAG` spec, which
* makes yt-dlp download a release binary from an ARBITRARY GitHub repo and
* overwrite the running yt-dlp.exe i.e. arbitrary, persistent code execution.
* The TypeScript type is erased at runtime and is no defence over IPC, so the
* value must be checked against this allowlist before it ever reaches the flag
* (see isYtdlpUpdateChannel; enforced in src/main/ytdlp.ts).
*/
export const YTDLP_UPDATE_CHANNELS = ['stable', 'nightly'] as const
export type YtdlpUpdateChannel = (typeof YTDLP_UPDATE_CHANNELS)[number]
/** Runtime guard for an untrusted update-channel value crossing the IPC boundary. */
export function isYtdlpUpdateChannel(v: unknown): v is YtdlpUpdateChannel {
return typeof v === 'string' && (YTDLP_UPDATE_CHANNELS as readonly string[]).includes(v)
}
export interface YtdlpUpdateResult {
ok: boolean
@@ -196,6 +294,24 @@ export interface YtdlpUpdateResult {
error?: string
}
/**
* Status of a background (startup) yt-dlp auto-update, pushed main renderer on
* IpcChannels.ytdlpAutoUpdateStatus so the Settings UI can reflect a check that
* ran on its own no button click needed.
*/
export interface YtdlpAutoUpdateStatus {
/** 'checking' when the update starts; the rest are terminal */
phase: 'checking' | 'updated' | 'current' | 'error'
/** which channel was checked */
channel: YtdlpUpdateChannel
/** yt-dlp version after the run, when known (phases 'updated' | 'current') */
version?: string
/** epoch ms the check finished (terminal phases) — feeds the "last checked" line */
checkedAt?: number
/** error text when phase === 'error' */
error?: string
}
/** A single selectable output format, derived from `yt-dlp -J`. */
export interface FormatOption {
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
@@ -292,6 +408,13 @@ export interface StartDownloadOptions {
* download even when the settings default is enabled.
*/
extraArgs?: string
/**
* Optional trim: keep only these time ranges. Raw user text ranges like
* '1:30-2:00', comma- or newline-separated normalised to yt-dlp
* `--download-sections` specs in buildArgs (parseTrimSections). Empty or
* undefined downloads the whole media.
*/
trim?: string
/**
* Metadata the renderer already fetched when probing the URL (title/channel/
* duration). When present, main uses it directly instead of spawning a second
@@ -340,8 +463,10 @@ export type DownloadEvent =
/** Persisted user settings (electron-store). */
export interface Settings {
/** absolute output directory (empty string resolves to the OS Downloads folder) */
outputDir: string
/** where video downloads are saved; empty string = the default Documents\Video folder */
videoDir: string
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
audioDir: string
defaultKind: MediaKind
defaultVideoQuality: string
defaultAudioQuality: string
@@ -367,10 +492,27 @@ export interface Settings {
cookieSource: CookieSource
/** which browser to read cookies from when cookieSource is 'browser' */
cookiesBrowser: CookieBrowser
/**
* YouTube reliability (Phase P). When set, emitted as
* `--extractor-args "youtube:player_client=…;po_token=…"`:
* - playerClient: an alternate extraction client (e.g. 'web_safari', 'tv',
* 'mweb') a common workaround for YouTube throttling/extraction breakage.
* - poToken: a manually-supplied Proof-of-Origin token for the bot check.
* Both empty = yt-dlp's defaults. (Automatic WebView token minting is deferred;
* this is the argv plumbing it would feed.)
*/
youtubePlayerClient: string
youtubePoToken: string
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
downloadArchive: boolean
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
autoUpdateYtdlp: boolean
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
ytdlpChannel: YtdlpUpdateChannel
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
ytdlpLastUpdateCheck: number
/** apply a named custom-command template's extra args to every new download */
customCommandEnabled: boolean
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
@@ -381,6 +523,8 @@ export interface Settings {
autoDownloadNew: boolean
/** false until the first-run welcome screen has been dismissed */
hasCompletedOnboarding: boolean
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
minimizeToTray: boolean
}
/**
@@ -393,6 +537,13 @@ export interface CommandTemplate {
id: string
name: string
args: string
/**
* Optional regex (matched case-insensitively against the download URL). When
* set and custom commands are enabled, this template auto-applies to any URL it
* matches taking precedence over the global defaultTemplateId. Empty/undefined
* means the template is only applied when explicitly chosen as the default.
*/
urlPattern?: string
}
/** Result of building the exact yt-dlp command line for the current form state, without running it. */
@@ -555,3 +706,29 @@ export interface ScheduledSyncStatus {
enabled: boolean
error?: string
}
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
// and streams its output. The binary is fixed to yt-dlp (never arbitrary exes),
// and the feature is gated on the same customCommandEnabled consent flag as the
// per-download extra args (extra args can run code via --exec — audit F2).
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
export type TerminalEvent =
| { type: 'output'; id: string; line: string; stream: 'stdout' | 'stderr' }
| { type: 'done'; id: string; code: number | null }
| { type: 'error'; id: string; error: string }
/** Result of starting a terminal command (pre-spawn validation only). */
export interface TerminalRunResult {
ok: boolean
error?: string
}
/** Overall queue state for the Windows taskbar progress bar (Phase O). */
export interface TaskbarProgress {
/** 0..1 overall fraction; ignored when mode is 'none' */
fraction: number
/** taskbar bar mode: hidden, normal, or red (some failed) */
mode: 'none' | 'normal' | 'error'
}
+225
View File
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
parseTrimSections,
selectExtraArgs,
formatCommandLine,
sanitizeDirSegment,
collectionOutputTemplate,
@@ -328,6 +330,80 @@ describe('parseExtraArgs', () => {
})
})
describe('selectExtraArgs — custom-command consent gate (audit F2)', () => {
const templates = [
{ id: 'thumb', args: '--write-thumbnail' },
{ id: 'danger', args: '--exec "calc.exe"' }
]
it('returns [] when custom commands are disabled, even with a per-download override', () => {
// The core fix: a renderer-supplied extraArgs must NOT run while the gate is off.
expect(
selectExtraArgs({
customCommandEnabled: false,
perDownloadExtraArgs: '--exec "calc.exe"',
defaultTemplateId: 'danger',
templates
})
).toEqual([])
})
it('returns [] when disabled even if a default template id is set', () => {
expect(
selectExtraArgs({
customCommandEnabled: false,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
})
).toEqual([])
})
it('parses a per-download override when enabled', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: '--write-thumbnail --no-mtime',
defaultTemplateId: null,
templates
})
).toEqual(['--write-thumbnail', '--no-mtime'])
})
it('an explicit empty override yields [] even with a default template set', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: '',
defaultTemplateId: 'thumb',
templates
})
).toEqual([])
})
it('falls back to the default template when no per-download override is given', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'thumb',
templates
})
).toEqual(['--write-thumbnail'])
})
it('returns [] when the default template id matches nothing', () => {
expect(
selectExtraArgs({
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: 'missing',
templates
})
).toEqual([])
})
})
describe('formatCommandLine', () => {
it('joins the exe and args with spaces when nothing needs quoting', () => {
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
@@ -385,6 +461,147 @@ describe('sidecar files', () => {
})
})
describe('YouTube extractor-args', () => {
it('combines player_client and po_token into one youtube: group', () => {
const argv = build(opts(), dlo(), {
...NO_ACCESS,
youtubePlayerClient: 'web_safari',
youtubePoToken: 'web.gvs+ABC'
})
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', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' })
expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true)
})
it('emits nothing when both are empty', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--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' }))
expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true)
expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false)
})
it('falls back to the codec tiebreaker when formatSort is empty', () => {
const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' }))
expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true)
})
})
describe('selectExtraArgs url-pattern matching', () => {
const base = {
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: null as string | null
}
const templates = [
{ id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' },
{ id: 'b', args: '--write-thumbnail' }
]
it('auto-applies a template whose urlPattern matches the URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' })
expect(out).toEqual(['--audio-quality', '0'])
})
it('does not apply a urlPattern template to a non-matching URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' })
expect(out).toEqual([])
})
it('urlPattern match takes precedence over the global default template', () => {
const out = selectExtraArgs({
...base,
defaultTemplateId: 'b',
templates,
url: 'https://soundcloud.com/x'
})
expect(out).toEqual(['--audio-quality', '0'])
})
it('a bad regex never matches (and is skipped safely)', () => {
const out = selectExtraArgs({
...base,
templates: [{ id: 'c', args: '--x', urlPattern: '(' }],
url: 'https://soundcloud.com/x'
})
expect(out).toEqual([])
})
})
describe('chapters', () => {
it('emits --split-chapters when splitChapters is on', () => {
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
})
it('does not emit --split-chapters by default', () => {
expect(build(opts(), dlo())).not.toContain('--split-chapters')
})
it('emits --split-chapters independently of --embed-chapters', () => {
const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false }))
expect(argv).toContain('--split-chapters')
expect(argv).not.toContain('--embed-chapters')
})
})
describe('parseTrimSections', () => {
it('normalises plain time ranges to *START-END specs', () => {
expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00'])
expect(parseTrimSections('90-120')).toEqual(['*90-120'])
expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20'])
})
it('splits on commas and newlines and trims whitespace', () => {
expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([
'*0:30-1:00',
'*2:00-2:30',
'*3:00-3:30'
])
})
it('keeps an existing leading * but does not double it', () => {
expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00'])
})
it('drops malformed tokens so garbage never reaches the argv', () => {
expect(parseTrimSections('not-a-range')).toEqual([])
expect(parseTrimSections('1:30')).toEqual([]) // no end
expect(parseTrimSections('')).toEqual([])
expect(parseTrimSections(undefined)).toEqual([])
})
})
describe('trim (--download-sections)', () => {
it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => {
const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo())
expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true)
expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true)
expect(argv).toContain('--force-keyframes-at-cuts')
})
it('emits nothing when there is no trim', () => {
const argv = build(opts(), dlo())
expect(argv).not.toContain('--download-sections')
})
it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => {
const argv = build(
opts({ trim: '0:30-1:00' }),
dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
)
expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1)
})
})
// --- Collection folder paths (Phase G, media-manager) -----------------------
describe('sanitizeDirSegment', () => {
@@ -410,6 +627,14 @@ describe('sanitizeDirSegment', () => {
expect(sanitizeDirSegment('com1')).toBe('_com1')
})
it('prefixes a reserved device name even when it carries an extension (audit T3)', () => {
expect(sanitizeDirSegment('CON.txt')).toBe('_CON.txt')
expect(sanitizeDirSegment('nul.mp4')).toBe('_nul.mp4')
expect(sanitizeDirSegment('LPT1.foo.bar')).toBe('_LPT1.foo.bar')
// a non-reserved name that merely starts with similar letters is left alone
expect(sanitizeDirSegment('console.log')).toBe('console.log')
})
it('falls back to Untitled for empty / all-illegal input', () => {
expect(sanitizeDirSegment('')).toBe('Untitled')
expect(sanitizeDirSegment('???')).toBe('Untitled')
+26
View File
@@ -42,6 +42,20 @@ describe('extractIncomingUrl — aerofetch:// protocol', () => {
it('returns null when there is no url= param', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://download'])).toBeNull()
})
it('normalises the target, stripping embedded control chars (audit T3 / F5)', () => {
// A tab spliced into the inner URL must not survive to the renderer banner.
const raw = 'https://www.youtube.com/watch?v=abc\tdef'
const arg = `aerofetch://download?url=${encodeURIComponent(raw)}`
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(
'https://www.youtube.com/watch?v=abcdef'
)
})
it('matches the aerofetch:// scheme case-insensitively (audit T5)', () => {
const arg = `AEROFETCH://download?url=${encodeURIComponent(TARGET)}`
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(TARGET)
})
})
describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', () => {
@@ -62,6 +76,18 @@ describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', (
it('ignores files that merely end in .url-like text but are not real paths', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'not-a-real-path.url'])).toBeNull()
})
it('reads a URL= line that falls within the 64 KB size cap (audit T5)', () => {
const junk = '; padding\r\n'.repeat(100) // ~1 KB of leading content
const path = urlFile('Small.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBe(TARGET)
})
it('ignores a URL= line that falls past the 64 KB size cap (audit T5)', () => {
const junk = '; padding\r\n'.repeat(8000) // ~88 KB, beyond the read window
const path = urlFile('Huge.url', `[InternetShortcut]\r\n${junk}URL=${TARGET}\r\n`)
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBeNull()
})
})
describe('extractIncomingUrl — no match', () => {
+25
View File
@@ -4,6 +4,7 @@ import {
buildMediaItems,
mergeItemsPreservingState,
buildFeedUrl,
isYouTubeFeedUrl,
parseRssVideoIds,
entryUrl,
fmtDuration,
@@ -57,6 +58,10 @@ describe('entryUrl', () => {
expect(entryUrl({ url: 'javascript:alert(1)' })).toBeNull()
expect(entryUrl({})).toBeNull()
})
it('percent-encodes an untrusted id rather than splicing it raw (audit T2)', () => {
expect(entryUrl({ id: 'ab cd&x=1' })).toBe('https://www.youtube.com/watch?v=ab%20cd%26x%3D1')
expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123') // unchanged
})
})
describe('fmtDuration', () => {
@@ -189,6 +194,26 @@ describe('buildFeedUrl', () => {
})
})
describe('isYouTubeFeedUrl (audit T7 — SSRF guard)', () => {
it('accepts a youtube feeds URL (www optional) and what buildFeedUrl produces', () => {
expect(isYouTubeFeedUrl('https://www.youtube.com/feeds/videos.xml?channel_id=UCabc')).toBe(true)
expect(isYouTubeFeedUrl('https://youtube.com/feeds/videos.xml?playlist_id=PLxyz')).toBe(true)
expect(isYouTubeFeedUrl(buildFeedUrl('channel', 'UCabc')!)).toBe(true)
expect(isYouTubeFeedUrl(buildFeedUrl('playlist', 'PLxyz')!)).toBe(true)
})
it('rejects internal/arbitrary hosts, wrong scheme, and wrong path (SSRF vectors)', () => {
expect(isYouTubeFeedUrl('http://169.254.169.254/latest/meta-data/')).toBe(false) // cloud metadata
expect(isYouTubeFeedUrl('http://localhost:8080/admin')).toBe(false)
expect(isYouTubeFeedUrl('https://evil.com/feeds/videos.xml')).toBe(false)
expect(isYouTubeFeedUrl('https://notyoutube.com.evil.com/feeds/videos.xml')).toBe(false)
expect(isYouTubeFeedUrl('http://www.youtube.com/feeds/videos.xml')).toBe(false) // not https
expect(isYouTubeFeedUrl('https://www.youtube.com/watch?v=x')).toBe(false) // wrong path
expect(isYouTubeFeedUrl('file:///etc/passwd')).toBe(false)
expect(isYouTubeFeedUrl('not a url')).toBe(false)
})
})
describe('parseRssVideoIds', () => {
it('extracts every <yt:videoId> from a feed body, in order', () => {
const xml = `
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest'
import { summarizeQueue, sameVideo } from '../src/renderer/src/store/queueStats'
import type { DownloadItem } from '../src/renderer/src/store/downloads'
// Minimal item factory — only the fields summarizeQueue reads.
function item(overrides: Partial<DownloadItem>): DownloadItem {
return {
id: Math.random().toString(36).slice(2),
url: 'https://youtube.com/watch?v=x',
title: 'x',
kind: 'video',
quality: '1080p',
status: 'queued',
progress: 0,
...overrides
}
}
describe('summarizeQueue', () => {
it('counts statuses and flags active', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5 }),
item({ status: 'queued' }),
item({ status: 'error' }),
item({ status: 'completed', progress: 1 }),
item({ status: 'canceled' })
])
expect(s.downloading).toBe(1)
expect(s.queued).toBe(1)
expect(s.failed).toBe(1)
expect(s.active).toBe(true)
})
it('is inactive and zeroed when nothing is downloading or queued', () => {
const s = summarizeQueue([item({ status: 'completed', progress: 1 })])
expect(s.active).toBe(false)
expect(s.progress).toBe(0)
expect(s.speedLabel).toBe('')
expect(s.etaLabel).toBe('')
})
it('averages progress over downloading + queued (queued counts as 0%)', () => {
// one at 80%, one queued at 0% → 40%
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.8 }),
item({ status: 'queued' })
])
expect(s.progress).toBeCloseTo(0.4, 5)
})
it('sums download speeds across MB/s and MiB/s strings', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.1, speed: '4.0 MB/s' }),
item({ status: 'downloading', progress: 0.1, speed: '2000 KiB/s' })
])
// 4.0 MB/s + ~2.0 MB/s ≈ 6.0 MB/s
expect(s.speedLabel).toBe('6.0 MB/s')
})
it('reports the longest active ETA as the time left', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5, eta: '0:30' }),
item({ status: 'downloading', progress: 0.2, eta: '2:10' })
])
expect(s.etaLabel).toBe('2:10')
})
})
describe('sameVideo', () => {
it('matches identical URLs', () => {
expect(sameVideo('https://x.com/a', 'https://x.com/a')).toBe(true)
})
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'
)
).toBe(true)
expect(
sameVideo(
'https://youtube.com/watch?v=dQw4w9WgXcQ&list=PL123',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
)
).toBe(true)
})
it('does not match different videos', () => {
expect(
sameVideo(
'https://youtube.com/watch?v=aaaaaaaaaaa',
'https://youtube.com/watch?v=bbbbbbbbbbb'
)
).toBe(false)
})
it('normalises www. and trailing slash for non-YouTube URLs', () => {
expect(sameVideo('https://www.vimeo.com/123/', 'https://vimeo.com/123')).toBe(true)
})
})
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest'
import { assertHttpUrl } from '../src/main/url'
import { isAllowedLoginUrl } from '../src/main/cookies'
import { isTrustedDownloadUrl, extractSha256, compareVersions } from '../src/main/updater'
import { isYtdlpUpdateChannel } from '@shared/ipc'
// --- F5: assertHttpUrl — argument-injection guard + normalisation -----------
describe('assertHttpUrl (audit F5)', () => {
it('accepts http(s) URLs and returns the normalised href', () => {
expect(assertHttpUrl('https://www.youtube.com/watch?v=abc')).toBe(
'https://www.youtube.com/watch?v=abc'
)
// http with a bare host normalises to a trailing slash
expect(assertHttpUrl('http://example.com')).toBe('http://example.com/')
})
it('trims surrounding whitespace', () => {
expect(assertHttpUrl(' https://example.com/v ')).toBe('https://example.com/v')
})
it('strips interior tabs/newlines the URL parser tolerates (normalised output)', () => {
// Returning the raw input would leak these through to argv / loadURL.
expect(assertHttpUrl('https://exa\nmple.com/v')).toBe('https://example.com/v')
const out = assertHttpUrl('https://example.com/a\tb')
expect(out).not.toContain('\t')
expect(out).not.toContain('\n')
})
it('rejects non-http(s) protocols', () => {
expect(() => assertHttpUrl('file:///C:/Windows/system32')).toThrow()
expect(() => assertHttpUrl('javascript:alert(1)')).toThrow()
expect(() => assertHttpUrl('ftp://example.com/x')).toThrow()
expect(() => assertHttpUrl('aerofetch://download?url=x')).toThrow()
})
it('rejects unparseable input', () => {
expect(() => assertHttpUrl('not a url')).toThrow()
expect(() => assertHttpUrl('')).toThrow()
})
it('can never return a value that begins with "-" (would read as a yt-dlp flag)', () => {
// A leading '-' cannot start a valid URL scheme, so it always throws —
// the returned value therefore never opens with a dash.
expect(() => assertHttpUrl('-https://example.com')).toThrow()
})
})
// --- T4: isAllowedLoginUrl — sign-in window navigation confinement ----------
describe('isAllowedLoginUrl (audit T4)', () => {
it('allows http(s) and about:blank', () => {
expect(isAllowedLoginUrl('https://accounts.google.com/signin')).toBe(true)
expect(isAllowedLoginUrl('http://example.com/login')).toBe(true)
expect(isAllowedLoginUrl('about:blank')).toBe(true)
})
it('blocks file://, the app protocol, and other external URI schemes', () => {
expect(isAllowedLoginUrl('file:///C:/Windows/System32/calc.exe')).toBe(false)
expect(isAllowedLoginUrl('aerofetch://download?url=https://evil.test')).toBe(false)
expect(isAllowedLoginUrl('ms-settings:')).toBe(false)
expect(isAllowedLoginUrl('mailto:x@y.z')).toBe(false)
expect(isAllowedLoginUrl('javascript:alert(1)')).toBe(false)
})
it('blocks unparseable input', () => {
expect(isAllowedLoginUrl('not a url')).toBe(false)
expect(isAllowedLoginUrl('')).toBe(false)
})
})
// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops -------
describe('isTrustedDownloadUrl (app-updater host pin)', () => {
const onHost =
'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
it('accepts https URLs on the exact update host (incl. port)', () => {
expect(isTrustedDownloadUrl(onHost)).toBe(true)
})
it('rejects a different host — the redirect/MITM bounce vector', () => {
expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false)
})
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)
})
it('rejects plain http even on the update host', () => {
expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false)
})
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)
})
it('rejects unparseable input', () => {
expect(isTrustedDownloadUrl('not a url')).toBe(false)
expect(isTrustedDownloadUrl('')).toBe(false)
})
})
// --- app-updater: extractSha256 — checksum-file parsing ----------------------
describe('extractSha256 (app-updater checksum parsing)', () => {
const hash = 'a'.repeat(64)
it('reads a bare lowercase digest', () => {
expect(extractSha256(hash)).toBe(hash)
})
it('reads sha256sum format (`<hash> filename`)', () => {
expect(extractSha256(`${hash} AeroFetch-Setup-0.5.0.exe\n`)).toBe(hash)
})
it('lowercases a PowerShell Get-FileHash (uppercase) digest', () => {
expect(extractSha256('A'.repeat(64))).toBe(hash)
})
it('returns null when there is no standalone 64-char hex token', () => {
expect(extractSha256('')).toBeNull()
expect(extractSha256('not a checksum')).toBeNull()
expect(extractSha256('deadbeef')).toBeNull() // too short
})
it('does not slice a 64-char window out of a longer hex run (e.g. sha512)', () => {
expect(extractSha256('a'.repeat(128))).toBeNull()
})
})
// --- app-updater: compareVersions — prerelease never outranks its release ----
describe('compareVersions (app-updater version ordering)', () => {
it('orders by numeric components', () => {
expect(compareVersions('0.5.0', '0.4.0')).toBe(1)
expect(compareVersions('0.4.0', '0.5.0')).toBe(-1)
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
})
it('treats a leading v as cosmetic', () => {
expect(compareVersions('v0.5.0', '0.5.0')).toBe(0)
})
it('never sorts a prerelease above its final release', () => {
// 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)
})
})
// --- F1: isYtdlpUpdateChannel — --update-to allowlist -----------------------
describe('isYtdlpUpdateChannel (audit F1)', () => {
it('accepts the two supported channels', () => {
expect(isYtdlpUpdateChannel('stable')).toBe(true)
expect(isYtdlpUpdateChannel('nightly')).toBe(true)
})
it('rejects an arbitrary repository spec (the --update-to RCE vector)', () => {
expect(isYtdlpUpdateChannel('evil/yt-dlp@latest')).toBe(false)
expect(isYtdlpUpdateChannel('owner/repo')).toBe(false)
})
it('rejects other yt-dlp channels not on AeroFetchs allowlist', () => {
expect(isYtdlpUpdateChannel('master')).toBe(false)
})
it('rejects non-string / empty values', () => {
expect(isYtdlpUpdateChannel('')).toBe(false)
expect(isYtdlpUpdateChannel(undefined)).toBe(false)
expect(isYtdlpUpdateChannel(null)).toBe(false)
expect(isYtdlpUpdateChannel(42)).toBe(false)
})
})
+66 -2
View File
@@ -4,9 +4,10 @@ import {
isSafeOutputDir,
isValidHistoryEntry,
isValidErrorLogEntry,
isTemplateLike
isTemplateLike,
isValidSource
} from '../src/main/validation'
import type { HistoryEntry, ErrorLogEntry } from '@shared/ipc'
import type { HistoryEntry, ErrorLogEntry, Source } from '@shared/ipc'
// --- S4: filename template path-traversal -----------------------------------
@@ -40,6 +41,24 @@ describe('isSafeFilenameTemplate', () => {
it('allows .. only when embedded in a longer segment (not a real traversal)', () => {
// '..foo' / 'foo..bar' are filenames, not parent-dir references.
expect(isSafeFilenameTemplate('my..video.%(ext)s')).toBe(true)
expect(isSafeFilenameTemplate('.%(title)s.%(ext)s')).toBe(true) // leading dot = hidden file
})
// --- T1: Windows-specific bypasses --------------------------------------
it('rejects a drive-relative prefix that path.isAbsolute misses', () => {
// 'C:foo' is NOT absolute per Node, but still escapes the output dir.
expect(isSafeFilenameTemplate('C:foo\\%(title)s.%(ext)s')).toBe(false)
expect(isSafeFilenameTemplate('c:%(title)s')).toBe(false)
})
it('rejects a .. segment dressed up with trailing/leading dots or spaces', () => {
// Windows trims trailing dots/spaces, so these all resolve to '..'.
expect(isSafeFilenameTemplate('%(title)s/.. /x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s/.. ./x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s/ ../x')).toBe(false)
expect(isSafeFilenameTemplate('%(title)s\\.. \\x')).toBe(false)
expect(isSafeFilenameTemplate('...')).toBe(false)
})
})
@@ -144,3 +163,48 @@ describe('isTemplateLike', () => {
expect(isTemplateLike('str')).toBe(false)
})
})
// --- T7: source row validation (feedUrl drives a network fetch) --------------
const validSource: Source = {
id: 's1',
url: 'https://www.youtube.com/@x',
kind: 'channel',
title: 'X',
addedAt: 1700000000000,
itemCount: 5
}
describe('isValidSource', () => {
it('accepts a well-formed source and its optional fields', () => {
expect(isValidSource(validSource)).toBe(true)
expect(
isValidSource({
...validSource,
channel: 'X',
watched: true,
feedUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=UC',
lastIndexedAt: 1700000000001
})
).toBe(true)
})
it('rejects a non-string feedUrl (audit T7)', () => {
expect(isValidSource({ ...validSource, feedUrl: 123 })).toBe(false)
})
it('rejects a non-boolean watched (audit T7)', () => {
expect(isValidSource({ ...validSource, watched: 'yes' })).toBe(false)
})
it('rejects a wrong kind or a missing required field', () => {
expect(isValidSource({ ...validSource, kind: 'video' })).toBe(false)
const { id: _id, ...noId } = validSource
expect(isValidSource(noId)).toBe(false)
})
it('rejects non-objects', () => {
expect(isValidSource(null)).toBe(false)
expect(isValidSource('nope')).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { shouldAutoCheckYtdlp, AUTO_UPDATE_INTERVAL_MS } from '../src/main/ytdlpPolicy'
const NOW = 1_782_435_469_486 // arbitrary fixed "now"
describe('shouldAutoCheckYtdlp', () => {
it('never checks when auto-update is disabled', () => {
expect(shouldAutoCheckYtdlp(false, 0, NOW)).toBe(false)
// …even if a full interval has elapsed.
expect(shouldAutoCheckYtdlp(false, NOW - AUTO_UPDATE_INTERVAL_MS * 2, NOW)).toBe(false)
})
it('checks on first run (lastCheck is 0 / never)', () => {
expect(shouldAutoCheckYtdlp(true, 0, NOW)).toBe(true)
})
it('skips while inside the throttle window', () => {
// Checked one minute ago → far inside the 24h window.
expect(shouldAutoCheckYtdlp(true, NOW - 60_000, NOW)).toBe(false)
// Exactly one ms short of the interval still skips.
expect(shouldAutoCheckYtdlp(true, NOW - (AUTO_UPDATE_INTERVAL_MS - 1), NOW)).toBe(false)
})
it('checks once the interval has elapsed (boundary inclusive)', () => {
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS, NOW)).toBe(true)
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS * 3, NOW)).toBe(true)
})
it('honours a custom interval', () => {
const hour = 60 * 60 * 1000
expect(shouldAutoCheckYtdlp(true, NOW - 30 * 60_000, NOW, hour)).toBe(false)
expect(shouldAutoCheckYtdlp(true, NOW - hour, NOW, hour)).toBe(true)
})
})