15 Commits

Author SHA1 Message Date
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
debont80 831d0a7dc2 chore: bump version to 0.2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:10:52 -04:00
debont80 47da3e6746 Add Pinchflat-style media manager: index channels into playlist folders
Index an entire YouTube channel or playlist once, then download it into
organized <Channel>/<Playlist>/<NNN> - <Title> folders, with incremental
re-sync. Built in six rebuild-gated phases (F-K); see ROADMAP-PINCHFLAT.md.

- F: channel-walk indexer (/playlists + /videos) -> persisted Source +
  MediaItem JSON stores; pure classify/dedup logic in indexerCore.ts
- G: Windows-safe folder paths + dir sanitizer (collectionOutputTemplate)
- H: Library tab + source tree + "Download pending" into the existing queue
- I: state-preserving re-index merge + persist-downloaded-on-complete
- J: watched sources, RSS fast-check, Task Scheduler + --sync launch
  (the OS-level scheduling/RSS need a real-install smoke test)
- K: .info.json / thumbnail / .description sidecars for media servers

Also gitignore .gitea-token. 106 unit tests pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:50:13 -04:00
46 changed files with 4340 additions and 527 deletions
+3
View File
@@ -7,3 +7,6 @@ dist
# Local screen-capture frames (not source)
.frames/
# Secrets — never commit
.gitea-token
+235
View File
@@ -0,0 +1,235 @@
# AeroFetch Roadmap — media-manager mode (Pinchflat parity)
The [Seal-parity roadmap](ROADMAP.md) took AeroFetch from "paste a URL → download" to a
full-featured yt-dlp frontend (Phases AE, complete). This document is a **second, parallel
track**: turning AeroFetch from a one-shot *downloader* into a *media manager* in the spirit
of [Pinchflat](https://github.com/kieraneglin/pinchflat) — download **entire channels**,
keep them **organized into `Channel / Playlist / Title` folders**, and (eventually) **keep
them in sync** as the channel posts new videos.
Nothing here removes or replaces existing behaviour. The single-video / hand-picked-playlist
flow (probe → format pick → queue → history) stays exactly as-is and remains the default. The
media-manager features are **additive**: a new "Library" section feeds the *same* download
queue, concurrency cap, history, error log, cookies, and post-processing options already built.
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).
---
## Implementation status — Phases FK shipped (2026-06-23)
All six phases are implemented and **rebuild-gated** (`npm run typecheck` + `npm run test`
+ `npm run build` clean after each). The unit suite grew from 76 → **106 tests** (new pure
logic in `indexerCore.ts` + the folder/sidecar args). UI phases were additionally verified
in the Vite browser preview.
| Phase | What shipped | Verification |
| --- | --- | --- |
| **F** | `classifySource`/`buildMediaItems`/`stableSourceId` (`indexerCore.ts`); channel-walk + persist (`indexer.ts`, `sources.ts`); IPC + preload | typecheck · 88 tests · build |
| **G** | `sanitizeDirSegment` + `collectionOutputTemplate` (`buildArgs.ts`); `CollectionContext` threaded through `buildCommand` | typecheck · 97 tests · build |
| **H** | Library sidebar tab + `LibraryView.tsx` + `store/sources.ts`; grouped tree, "Download N pending" → existing queue, live status pills | **preview-verified** |
| **I** | `mergeItemsPreservingState` (re-index keeps downloaded state, drops vanished, counts new); persist-on-complete via `markDownloaded` | **preview-verified** (state survives a queue clear) |
| **J** | Watched sources + RSS fast-check (`sync.ts`), per-source Watch toggle, "Check for new", `autoDownloadNew`; Task Scheduler (`schedule.ts`) + `--sync` launch | UI preview-verified; **OS-level scheduling/RSS fenced — needs a real-install smoke test** (same caveat as the `aerofetch://` wiring in ROADMAP.md) |
| **K** | `.info.json` / thumbnail / `.description` sidecars via `DownloadOptions` + `DownloadOptionsForm` "Sidecar files" group | typecheck · 106 tests · build + preview-verified |
**Deferred sub-items (noted, not yet built):** the power-user raw output-path template
setting (G bullet 3 — current layout is the fixed `Channel/Playlist/NNN - Title`); an
automatic incremental feeder so the live queue self-refills (H — today "Download pending"
enqueues a capped batch of `MAX_ENQUEUE_BATCH`); the retention / quality-upgrade re-download
(I stretch); a true headless quit-when-done for the scheduled `--sync` run (J — today it
launches the normal window unobtrusively and syncs on startup); and Kodi/Jellyfin `.nfo`
generation (K — the three native yt-dlp sidecars ship; `.nfo` needs a custom post-step).
---
## The core idea borrowed from Pinchflat
Pinchflat's insight is to **separate indexing from downloading**, with a persisted index in
between:
1. **Index** a *Source* (a channel or playlist) — enumerate *all* its videos with
`yt-dlp --flat-playlist` and persist one lightweight record per video, **regardless of any
filters**. Changing what you want to download never forces a re-index.
2. **Filter** that persisted list to decide what to actually fetch.
3. **Download** each chosen video as its own per-item yt-dlp run, placed into folders by an
output-path template, with `--download-archive` + a per-item `downloaded` flag preventing
re-downloads.
This is why "download an entire channel" doesn't have to flood the queue with thousands of
live cards (the failure mode of naïve per-video enumeration) and doesn't need a custom
playlist-progress parser (the failure mode of native `yt-dlp` playlist expansion in one
process): **the channel's full video list lives in a store; the live queue only holds what's
actively downloading.** AeroFetch's per-video progress, concurrency, cancel, retry, and
history all keep working unchanged.
### Data model — Pinchflat → AeroFetch
| Pinchflat (Elixir + SQLite + Oban) | AeroFetch (Electron + JSON store + existing queue) |
| --- | --- |
| **Source** (watched channel/playlist) | `Source` record in a new `sources.json` (mirrors `history.ts`) |
| **Media Item** (one discovered video) | `MediaItem` record (id, title, playlist, index, source id, `downloaded`) |
| **Media Profile** (download preset) | Maps onto existing `DownloadOptions` + `CommandTemplate` + a new output-path template |
| Oban `media_collection_indexing` queue | An async **index job** in main (the probe, persisted) |
| Oban `media_fetching` queue | The existing renderer download queue + concurrency cap |
| Oban `fast_indexing` (RSS) | Phase J — YouTube RSS re-sync |
| `--download-archive` + item state | The existing `--download-archive` setting + `MediaItem.downloaded` |
**Storage decision:** stay with plain-JSON stores (the pattern Phase D deliberately kept over
better-sqlite3 to avoid a native-module build step). A single channel is up to a few thousand
`MediaItem`s — still fine to hold and filter client-side. Revisit better-sqlite3 only if a
user indexes many large channels and the JSON files get unwieldy (noted as a Phase H risk).
---
## Phase F — Channel & playlist indexing (foundation) ✅ COMPLETE
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`,
`/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`)
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
```ts
interface Source {
id: string
url: string
kind: 'channel' | 'playlist'
title: string
channel?: string
addedAt: number
lastIndexedAt?: number
}
interface MediaItem {
id: string // yt-dlp video id — the dedup key
sourceId: string
title: string
playlistTitle?: string // for the folder path; 'Uploads' fallback
playlistIndex?: number // 1-based, for NNN numbering
durationLabel?: string
downloaded: boolean
downloadedAt?: number
filePath?: string
}
```
- [ ] **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
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
## Phase G — Folder organization & output-path templates ✅ COMPLETE
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
`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
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.
- [ ] **Output-path setting.** A new Settings → Downloads field for the folder layout, defaulting
to `Channel / Playlist / Title`, with the raw yt-dlp `-o` template exposed for power users
(parallels how Phase C exposed raw extra args). Keeps the existing flat
`filenameTemplate` as the default for non-collection downloads.
- [ ] **"Media Profile" = named download preset (optional sugar).** Pinchflat bundles
quality + subs + output template into a reusable Profile. AeroFetch already persists
`DownloadOptions` and named `CommandTemplate`s; a Profile is just a named bundle of
`{ DownloadOptions, outputTemplate, extraArgs }` a Source can point at. Worth it once
multiple sources want different rules; skip for v1 (use the global defaults).
## Phase H — Library view (the media-manager UI) ✅ COMPLETE
The new surface where channels live. Everything below feeds the **existing** download queue.
- [ ] **"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
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
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
`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,
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
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,
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.
- [ ] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade
quality/metadata, and optional pruning of old items. Defer — niche for a desktop app.
## Phase J — Subscriptions & scheduled auto-download ✅ COMPLETE (OS wiring needs smoke test)
Pinchflat's headline feature: a Source you *watch*, that downloads new uploads on its own.
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
(`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
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
`Notification` path.
## Phase K — Media-server output (optional stretch) ✅ COMPLETE
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`,
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.
---
## What we reuse vs. build
**Reuse unchanged:** the download queue + concurrency cap + cancel/retry (`store/downloads.ts`),
history + error log, cookies + proxy + rate-limit + aria2c (`AccessOptions`), all
post-processing `DownloadOptions`, `--download-archive`, native notifications, the
single-instance lock, and the JSON-store pattern.
**Build new:** channel-depth probing (Phase F), the `sources.json` index + `Source`/`MediaItem`
model (F), per-item folder output paths + sanitizer (G), the Library view + `useSources` store
(H), incremental diff-sync (I), and RSS + scheduled sync (J).
## Suggested order
F → G are the foundation and unlock a usable "download whole channel into folders" the moment
they land (drive it from a temporary button before the full Library UI exists). H makes it a
real product surface. I and J turn it into a true media manager. K is polish. Phases FH are
the high-value core; IK are incremental and each shippable on its own.
+5
View File
@@ -9,6 +9,11 @@ Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
[Seal on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ·
[It's FOSS overview](https://itsfoss.com/news/seal/).
> **See also:** [ROADMAP-PINCHFLAT.md](ROADMAP-PINCHFLAT.md) — a parallel track that extends
> 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.
---
## Already at parity ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aerofetch",
"version": "0.1.0",
"version": "0.4.1",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
+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
+12
View File
@@ -35,3 +35,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)
}
+91 -1
View File
@@ -8,11 +8,14 @@
* resolving it itself; the caller in download.ts passes `getBinDir()`.
*/
import { join } from 'path'
import {
BEST_FORMAT_ID,
type StartDownloadOptions,
type DownloadOptions,
type CookieBrowser
type CollectionContext,
type CookieBrowser,
type CommandTemplate
} from '@shared/ipc'
// --- Quality → yt-dlp selector mapping --------------------------------------
@@ -125,6 +128,39 @@ 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 matching defaultTemplateId → that template's args
* - else → []
*/
export function selectExtraArgs(params: {
customCommandEnabled: boolean
perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args'>[]
}): string[] {
if (!params.customCommandEnabled) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
if (params.defaultTemplateId) {
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
}
// 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 {
@@ -138,6 +174,54 @@ export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
// --- Collection (media-manager) folder paths --------------------------------
/**
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
* directory name. This matters because, unlike the filename yt-dlp itself writes
* (which `--restrict-filenames` can clean), these directory segments are built by
* AeroFetch from untrusted channel/playlist titles and joined onto the output
* dir — so they must be neutered for both illegal characters AND path traversal.
*
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
* which also turns a bare `..` traversal segment into nothing
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
* - length-capped so the full path stays well under MAX_PATH
* - empty result falls back to 'Untitled'
*/
export function sanitizeDirSegment(name: string): string {
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
// space. The control-char filter is done by char code so no literal control
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
// 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'
}
/**
* Build the `-o` output template for a collection download:
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
* where NNN is the 1-based playlist index zero-padded to three digits and
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
*/
export function collectionOutputTemplate(
outDir: string,
c: CollectionContext,
baseFilename: string
): string {
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
const nnn = String(n).padStart(3, '0')
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
@@ -179,6 +263,12 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
if (o.embedChapters) args.push('--embed-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
if (o.writeInfoJson) args.push('--write-info-json')
if (o.writeThumbnailFile) args.push('--write-thumbnail')
if (o.writeDescription) args.push('--write-description')
return args
}
+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)
+66 -21
View File
@@ -1,13 +1,26 @@
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 { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
import { isSafeOutputDir } from './validation'
import {
buildArgs,
selectExtraArgs,
formatCommandLine,
collectionOutputTemplate
} from './buildArgs'
import { cleanError } from './log'
import { addErrorLog } from './errorlog'
import {
@@ -146,22 +159,40 @@ 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()
})
}
/** 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')
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
// 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'
const outputTemplate = opts.collection
? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s')
: join(outDir, filenameTemplate)
// Per-download override wins; otherwise use the persisted defaults.
const options = opts.options ?? settings.downloadOptions
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
@@ -183,18 +214,21 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, join(outDir, template), options, getBinDir(), access, extraArgs)
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
}
/** 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 }
}
@@ -229,9 +263,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 }
}
@@ -334,8 +372,15 @@ export function cancelDownload(id: string): void {
rec.canceled = true
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 }, () => {})
// Kill the whole 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)
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
rec.child.kill()
}
+89 -2
View File
@@ -11,9 +11,10 @@ import {
type SystemThemeInfo
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } from './settings'
import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
@@ -22,6 +23,17 @@ import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { exportBackup, importBackup } from './backup'
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
import {
listSources,
getSource,
removeSource,
listMediaItems,
setMediaItemDownloaded,
setSourceWatched
} from './sources'
import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
@@ -79,6 +91,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,
@@ -96,7 +127,10 @@ function createWindow(): void {
mainWindow = win
win.on('ready-to-show', () => {
win.show()
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
if (isSyncLaunch(process.argv)) win.showInactive()
else win.show()
})
win.on('closed', () => {
@@ -129,6 +163,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 {
@@ -137,6 +180,14 @@ 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.probe, (_e, url: string) => probeMedia(url))
@@ -229,6 +280,38 @@ function registerIpcHandlers(): void {
}
return result
})
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
setMediaItemDownloaded(id, filePath)
)
// Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result.
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
indexSource(url, (p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
)
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
const src = getSource(id)
if (!src) return { ok: false, error: 'Source not found.' }
return indexSource(src.url, (p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
})
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
setSourceWatched(id, watched)
)
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
syncWatchedSources((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
)
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
}
// Push OS theme/contrast changes to every window, and keep the native
@@ -257,6 +340,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)
})
+205
View File
@@ -0,0 +1,205 @@
/**
* Source indexing orchestration (Pinchflat-style media manager; see
* ROADMAP-PINCHFLAT.md). Walks a channel (its /playlists + /videos tabs) or a
* single playlist with `yt-dlp --flat-playlist`, merges the result into a deduped
* MediaItem list, and persists it as a Source. The download step (Phase G+) then
* pulls from that persisted list rather than the live queue holding it all.
*
* The pure URL-classification + merge logic lives in indexerCore.ts (unit-tested);
* this module is the impure shell that spawns yt-dlp and writes to disk.
*/
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import {
classifySource,
buildMediaItems,
buildFeedUrl,
entryUrl,
stripTabSuffix,
stableSourceId,
type RawEntry,
type NamedPlaylist
} from './indexerCore'
import { getSource, upsertSource, mergeMediaItems } from './sources'
import type { IndexProgress, IndexSourceResult, Source, SourceKind } from '@shared/ipc'
/** The slice of `yt-dlp -J --flat-playlist` output the indexer reads. */
interface FlatInfo {
_type?: string
id?: string
title?: string
uploader?: string
channel?: string
/** the UC… channel id, used to build the RSS feed URL */
channel_id?: string
entries?: RawEntry[]
}
/**
* Run `yt-dlp -J --flat-playlist` on a URL and parse the JSON. Rejects on a
* non-zero exit or unparseable output. maxBuffer is large because a big channel's
* flat upload list can be a few MB of JSON; the timeout is generous for the same
* reason. `--` terminates option parsing so the URL can't be read as a flag.
*/
function probeFlat(url: string): Promise<FlatInfo> {
return new Promise((resolve, reject) => {
execFile(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out indexing source. Check the link or your connection.'
: cleanError(stderr) || err.message
reject(new Error(msg))
return
}
try {
resolve(JSON.parse(stdout) as FlatInfo)
} catch {
reject(new Error('Could not parse source info from yt-dlp.'))
}
}
)
})
}
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
function probeTab(url: string): Promise<FlatInfo | null> {
return probeFlat(url).catch(() => null)
}
/**
* Index (or re-index) a Source. Resolves to the persisted Source + item count, or
* an error. `onProgress` is invoked throughout so the renderer can show a live
* status line — it's best-effort and never affects the result.
*/
export async function indexSource(
url: string,
onProgress: (p: IndexProgress) => void
): Promise<IndexSourceResult> {
let normalizedUrl: string
try {
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
if (!existsSync(getYtdlpPath())) {
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' }
}
const cls = classifySource(url)
onProgress({ url, phase: 'start', message: 'Reading source…' })
try {
let kind: SourceKind
let title: string
let channel: string | undefined
let feedId: string | undefined
const playlists: NamedPlaylist[] = []
let uploads: RawEntry[] = []
if (cls?.kind === 'channel') {
kind = 'channel'
// 1. Enumerate the channel's playlists, then each playlist's videos.
onProgress({ url, phase: 'playlists', message: 'Finding playlists…' })
const playlistTab = await probeTab(`${cls.base}/playlists`)
const playlistEntries = playlistTab?.entries ?? []
const total = playlistEntries.length
let done = 0
for (const pe of playlistEntries) {
done++
const purl = entryUrl(pe)
if (!purl) continue
onProgress({
url,
phase: 'playlist',
message: `Indexing playlist ${done}/${total}: ${pe.title ?? ''}`.trim(),
current: done,
total
})
const pdata = await probeTab(purl)
if (pdata?.entries?.length) {
playlists.push({ title: pe.title || 'Playlist', entries: pdata.entries })
}
}
// 2. The full uploads feed — the catch-all for videos in no playlist.
onProgress({ url, phase: 'uploads', message: 'Indexing channel uploads…' })
const videoTab = await probeTab(`${cls.base}/videos`)
uploads = videoTab?.entries ?? []
title =
stripTabSuffix(videoTab?.channel) ||
stripTabSuffix(videoTab?.title) ||
stripTabSuffix(playlistTab?.title) ||
cls.base
channel = stripTabSuffix(videoTab?.channel || videoTab?.uploader) || title
feedId = videoTab?.channel_id || playlistTab?.channel_id
} else {
// A single playlist (classified) — or an unclassified URL that might still
// 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 ?? normalizedUrl)
const entries = data.entries ?? []
if (entries.length === 0) {
return {
ok: false,
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
}
}
title = data.title || 'Playlist'
channel = data.uploader || data.channel
// A playlist feed keys off the playlist id (PL…), carried as `id` here.
feedId = data.id
// File everything under the playlist's own name (not the 'Uploads' fallback).
playlists.push({ title, entries })
}
const sourceId = stableSourceId(cls?.base ?? url.trim())
const fresh = buildMediaItems(sourceId, playlists, uploads)
if (fresh.length === 0) {
return { ok: false, error: 'No downloadable videos found for this source.' }
}
// Incremental merge: preserve the downloaded state of anything already on
// disk, and report how many videos are new since the last index.
const { items, newCount } = mergeMediaItems(sourceId, fresh)
const prev = getSource(sourceId)
const source: Source = {
id: sourceId,
url: url.trim(),
kind,
title,
channel,
addedAt: prev?.addedAt ?? Date.now(),
lastIndexedAt: Date.now(),
itemCount: items.length,
// Preserve the watched flag across a re-index; refresh the RSS feed URL.
watched: prev?.watched,
feedUrl: buildFeedUrl(kind, feedId) ?? prev?.feedUrl
}
upsertSource(source)
const newNote = newCount === items.length ? '' : ` (${newCount} new)`
onProgress({
url,
phase: 'done',
message: `Indexed ${items.length} videos${newNote}.`,
total: items.length
})
return { ok: true, source, itemCount: items.length, newCount }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
onProgress({ url, phase: 'error', message: msg })
return { ok: false, error: msg }
}
}
+246
View File
@@ -0,0 +1,246 @@
/**
* Pure, dependency-free core for Source indexing (Pinchflat-style media manager;
* see ROADMAP-PINCHFLAT.md). Like buildArgs.ts / validation.ts this module imports
* nothing from electron or the node runtime, so its URL-classification and
* item-merge logic can be unit-tested without spinning up Electron.
*
* The impure orchestration (spawning yt-dlp, persisting to disk) lives in
* indexer.ts, which composes these helpers.
*/
import type { MediaItem, SourceKind } from '@shared/ipc'
// --- yt-dlp --flat-playlist entry shape (shared with probe.ts) --------------
/** The slice of a flat-playlist entry we read (a video, or a nested playlist). */
export interface RawEntry {
id?: string
title?: string
url?: string
webpage_url?: string
duration?: number
uploader?: string
channel?: string
}
/**
* Resolve the canonical URL for a flat-playlist entry. Prefer an explicit http(s)
* URL; otherwise build a YouTube watch URL from the id (the common case where flat
* entries carry only an id). Returns null when nothing usable is present.
*
* Note: the returned value is always either an http(s) URL or null, so it can
* never begin with '-' and be mis-read as a yt-dlp option (callers also pass `--`).
*/
export function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
// 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
}
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat entries give duration as a number. */
export function fmtDuration(sec?: number): string | undefined {
if (sec == null || !Number.isFinite(sec)) return undefined
const s = Math.max(0, Math.round(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
const mm = h ? String(m).padStart(2, '0') : String(m)
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
}
// --- Source-URL classification ----------------------------------------------
/** A classified Source URL: the kind, plus a normalised base for tab probing. */
export interface SourceClass {
kind: SourceKind
/**
* For 'channel': the channel root (e.g. https://www.youtube.com/@handle) onto
* which '/videos' and '/playlists' tabs are appended. For 'playlist': the
* canonical playlist URL.
*/
base: string
}
/**
* Classify a pasted URL as a YouTube channel, a playlist, or neither.
*
* Channel forms: /@handle, /channel/<id>, /c/<name>, /user/<name> — any trailing
* tab (/videos, /playlists, /streams, /shorts, /featured) is stripped to the root.
* Playlist form: any URL carrying a `list=` query param. Returns null for a lone
* video or a non-YouTube URL (Phase F scopes the channel walk to YouTube, whose
* /videos + /playlists tab structure this relies on).
*/
export function classifySource(raw: string): SourceClass | null {
let u: URL
try {
u = new URL((raw ?? '').trim())
} catch {
return null
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be'
// A playlist is identified purely by its list= param (works on any youtube host).
const list = u.searchParams.get('list')
if (isYouTube && list) {
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
}
if (!isYouTube) return null
// Channel roots. The handle/id segment is captured; later path segments
// (the tab) are discarded so we always probe from the channel root.
const m = u.pathname.match(/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/i)
if (m) return { kind: 'channel', base: `https://www.youtube.com/${m[1]}` }
return null
}
/**
* Channel tab titles from yt-dlp often look like "<Name> - Videos" or
* "<Name> - Playlists". Strip a trailing known-tab suffix to recover the bare
* channel name. Leaves anything else untouched.
*/
export function stripTabSuffix(title: string | undefined): string | undefined {
if (!title) return title
return title.replace(/\s[-–—]\s(Videos|Playlists|Shorts|Live|Streams|Home|Featured)$/i, '').trim()
}
/**
* Deterministic 32-bit FNV-1a hash → 8 hex chars. Used to derive a stable Source
* id from its normalised base URL so re-indexing the same channel updates the
* existing record instead of creating a duplicate. Pure (no crypto import).
*/
export function stableSourceId(base: string): string {
let h = 0x811c9dc5
for (let i = 0; i < base.length; i++) {
h ^= base.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return (h >>> 0).toString(16).padStart(8, '0')
}
// --- RSS fast-check (Phase J: watched sources) ------------------------------
/**
* Build a YouTube RSS feed URL for cheap "anything new?" polling of a watched
* source. Channels feed by `channel_id` (UC…), playlists by `playlist_id` (PL…).
* Returns undefined when the id is missing (the sync then falls back to a full
* re-index). The feed only carries the latest ~15 uploads, so it's a freshness
* check, not a substitute for the initial full index.
*/
export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string | undefined {
if (!ytId) return undefined
const param = kind === 'channel' ? 'channel_id' : 'playlist_id'
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[] = []
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
let m: RegExpExecArray | null
while ((m = re.exec(xml)) !== null) ids.push(m[1])
return ids
}
// --- Entry merge / dedup ----------------------------------------------------
/** A named playlist and its (flat) video entries, ready to merge. */
export interface NamedPlaylist {
title: string
entries: RawEntry[]
}
/**
* Merge a Source's playlists (and its catch-all uploads) into a deduped list of
* MediaItem records. Dedup is by video id: the FIRST playlist a video appears in
* wins its folder assignment, so videos grouped into a real playlist land there,
* and only videos in no playlist fall through to the synthetic 'Uploads' folder.
*
* `playlistIndex` is the 1-based position WITHIN the winning playlist (not the
* global upload order), so the on-disk "NNN - Title" numbering matches the
* playlist the file is filed under.
*/
export function buildMediaItems(
sourceId: string,
playlists: NamedPlaylist[],
uploads: RawEntry[]
): MediaItem[] {
const byVideo = new Map<string, MediaItem>()
const add = (e: RawEntry, playlistTitle: string, index: number): void => {
const videoId = e.id
if (!videoId) return
if (byVideo.has(videoId)) return // first playlist wins
const url = entryUrl(e)
if (!url) return // not turnable into a downloadable URL
byVideo.set(videoId, {
id: `${sourceId}:${videoId}`,
sourceId,
videoId,
title: e.title || `Video ${videoId}`,
url,
playlistTitle,
playlistIndex: index,
durationLabel: fmtDuration(e.duration),
downloaded: false
})
}
for (const p of playlists) p.entries.forEach((e, i) => add(e, p.title, i + 1))
uploads.forEach((e, i) => add(e, 'Uploads', i + 1))
return [...byVideo.values()]
}
/**
* Merge a freshly-enumerated item list with the previously-persisted one for the
* same source (incremental re-index; see ROADMAP-PINCHFLAT.md Phase I). The fresh
* list defines the current membership/ordering, but any video already marked
* downloaded keeps its `downloaded`/`downloadedAt`/`filePath` so a re-sync never
* loses that state or re-downloads it. Videos that vanished from the source (now
* private/deleted) are dropped. `newCount` is how many fresh videos weren't in
* the previous set — the "X new since last sync" figure.
*/
export function mergeItemsPreservingState(
existing: MediaItem[],
fresh: MediaItem[]
): { items: MediaItem[]; newCount: number } {
const prevByVideo = new Map(existing.map((m) => [m.videoId, m]))
let newCount = 0
const items = fresh.map((f) => {
const prev = prevByVideo.get(f.videoId)
if (!prev) {
newCount++
return f
}
return prev.downloaded
? { ...f, downloaded: true, downloadedAt: prev.downloadedAt, filePath: prev.filePath }
: f
})
return { items, newCount }
}
+4 -35
View File
@@ -4,6 +4,7 @@ import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
import {
BEST_FORMAT_ID,
type ProbeResult,
@@ -26,16 +27,6 @@ interface RawFormat {
filesize_approx?: number
}
interface RawEntry {
id?: string
title?: string
url?: string
webpage_url?: string
duration?: number
uploader?: string
channel?: string
}
interface RawInfo {
_type?: string
title?: string
@@ -92,29 +83,6 @@ function buildInfo(data: RawInfo): MediaInfo {
}
}
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat playlist entries give duration as a number. */
function fmtDuration(sec?: number): string | undefined {
if (sec == null || !Number.isFinite(sec)) return undefined
const s = Math.max(0, Math.round(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
const mm = h ? String(m).padStart(2, '0') : String(m)
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
}
/**
* Resolve the URL we'll enqueue for a playlist entry. Prefer an explicit http(s)
* URL; fall back to a YouTube watch URL built from the id (the common case where
* flat entries carry only an id). Returns null when nothing usable is present.
*/
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}`
return null
}
function buildPlaylist(data: RawInfo): PlaylistInfo {
const entries: PlaylistEntry[] = []
;(data.entries ?? []).forEach((e, i) => {
@@ -150,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 })
}
@@ -160,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) {
+70
View File
@@ -0,0 +1,70 @@
/**
* Windows Task Scheduler integration for the daily watched-source sync
* (ROADMAP-PINCHFLAT.md Phase J). Registers a task that launches AeroFetch with
* `--sync` once a day; the app then runs its startup sync of watched sources.
*
* NOTE: this is OS-level wiring and cannot be exercised in the Vite UI preview or
* the unit tests — like the `aerofetch://` protocol registration, it needs a real
* install + manual smoke test. `schtasks` is invoked via execFile (no shell), and
* the only interpolated value is the trusted `process.execPath`.
*/
import { execFile } from 'child_process'
import { getSystem32Path } from './binaries'
import type { ScheduledSyncStatus } from '@shared/ipc'
const TASK_NAME = 'AeroFetchDailySync'
/** The argv flag the scheduled task passes so startup knows it's a sync launch. */
export const SYNC_FLAG = '--sync'
function schtasks(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
// Resolve schtasks from System32 by absolute path, not the bare name, so a
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
})
})
}
/** True when this launch came from the scheduled task (argv carries --sync). */
export function isSyncLaunch(argv: string[]): boolean {
return argv.includes(SYNC_FLAG)
}
/** Whether the daily-sync scheduled task is currently registered. */
export async function getScheduledSync(): Promise<ScheduledSyncStatus> {
const r = await schtasks(['/Query', '/TN', TASK_NAME])
return { enabled: r.code === 0 }
}
/**
* Register or remove the daily-sync scheduled task. Creating runs AeroFetch with
* `--sync` every day at 09:00 (overwriting any prior task of the same name).
*/
export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncStatus> {
if (enabled) {
const tr = `"${process.execPath}" ${SYNC_FLAG}`
const r = await schtasks([
'/Create',
'/F',
'/SC',
'DAILY',
'/ST',
'09:00',
'/TN',
TASK_NAME,
'/TR',
tr
])
return {
enabled: r.code === 0,
error: r.code === 0 ? undefined : r.stderr.trim() || 'Could not create the scheduled task.'
}
}
const r = await schtasks(['/Delete', '/F', '/TN', TASK_NAME])
// A missing task ("cannot find") is success for our purposes — it's already gone.
const gone = r.code === 0 || /cannot find|does not exist/i.test(r.stderr)
return { enabled: gone ? false : true, error: gone ? undefined : r.stderr.trim() }
}
+46 -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 {
@@ -16,7 +17,10 @@ import {
} 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)',
@@ -36,6 +40,7 @@ const DEFAULTS: Settings = {
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: false
}
@@ -44,6 +49,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.
@@ -77,7 +107,10 @@ function sanitizeOptions(input: unknown): DownloadOptions {
embedChapters: bool(o.embedChapters, d.embedChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail)
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
}
}
@@ -96,10 +129,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)
@@ -163,6 +195,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'downloadArchive':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value)
break
@@ -182,8 +215,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())) {
+135
View File
@@ -0,0 +1,135 @@
/**
* Persistence for the media-manager index (Pinchflat-style; see
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
* better-sqlite3 — revisit if a user indexes many large channels, see the
* Phase H risk note in the roadmap):
*
* sources.json — one Source record per added channel/playlist
* media-items.json — every MediaItem across all sources (queried by sourceId)
*
* Per-row validation on read (validation.ts) so a hand-edited or corrupted file
* can't feed the UI malformed records (same approach as history/errorlog).
*/
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore'
// A generous global cap so a runaway index can't grow the file unbounded; large
// enough for several big channels. When exceeded, the most-recently-written
// source's items are kept (they're placed first by replaceMediaItems).
const MAX_ITEMS = 20000
function sourcesFile(): string {
return join(app.getPath('userData'), 'sources.json')
}
function itemsFile(): string {
return join(app.getPath('userData'), 'media-items.json')
}
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
try {
if (!existsSync(path)) return []
const data = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(data) ? data.filter(isValid) : []
} catch {
return []
}
}
function writeJson(path: string, value: unknown): void {
try {
writeFileSync(path, JSON.stringify(value, null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted index */
}
}
// --- Sources ----------------------------------------------------------------
export function listSources(): Source[] {
return readJsonArray(sourcesFile(), isValidSource)
}
export function getSource(id: string): Source | undefined {
return listSources().find((s) => s.id === id)
}
/** Insert or replace a source by id (a re-index updates the existing record). */
export function upsertSource(source: Source): Source[] {
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
writeJson(sourcesFile(), sources)
return sources
}
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
export function setSourceWatched(id: string, watched: boolean): Source[] {
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
writeJson(sourcesFile(), sources)
return sources
}
/** Remove a source and all of its media items. Returns the remaining sources. */
export function removeSource(id: string): Source[] {
const sources = listSources().filter((s) => s.id !== id)
writeJson(sourcesFile(), sources)
const items = listAllItems().filter((m) => m.sourceId !== id)
writeJson(itemsFile(), items)
return sources
}
// --- Media items ------------------------------------------------------------
function listAllItems(): MediaItem[] {
return readJsonArray(itemsFile(), isValidMediaItem)
}
export function listMediaItems(sourceId: string): MediaItem[] {
return listAllItems().filter((m) => m.sourceId === sourceId)
}
/**
* Replace all media items for one source with a fresh set (the result of a
* (re)index). Other sources' items are preserved; the new items are placed first
* so they survive the MAX_ITEMS cap.
*/
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS))
}
/**
* Incrementally merge a freshly-indexed item list into the persisted store,
* preserving the downloaded state of videos already on disk (see Phase I). The
* fresh list defines current membership/order; returns the merged items and how
* many were new since the last index.
*/
export function mergeMediaItems(
sourceId: string,
fresh: MediaItem[]
): { items: MediaItem[]; newCount: number } {
const result = mergeItemsPreservingState(listMediaItems(sourceId), fresh)
replaceMediaItems(sourceId, result.items)
return result
}
/**
* Mark one media item downloaded, recording its file path + time. Returns the
* updated list for that item's source (or [] if the id is unknown). Used by the
* library view once a queued item completes (Phase H/I).
*/
export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem[] {
const all = listAllItems()
const target = all.find((m) => m.id === id)
if (!target) return []
const updated = all.map((m) =>
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
)
writeJson(itemsFile(), updated)
return updated.filter((m) => m.sourceId === target.sourceId)
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Watched-source sync (Pinchflat-style; ROADMAP-PINCHFLAT.md Phase J). Re-indexes
* every watched Source and reports the videos that are new since its last index.
* A YouTube RSS feed is used as a cheap pre-check so a source with no new uploads
* is skipped before the (more expensive) full `yt-dlp` re-index.
*/
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
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())
}
/**
* Re-index every watched source and collect the videos new since the last index.
* RSS pre-check: if the feed shows only ids the source already knows, the full
* re-index is skipped. `onProgress` is forwarded from the underlying indexSource.
*/
export async function syncWatchedSources(
onProgress: (p: IndexProgress) => void
): Promise<SyncResult> {
try {
const watched = listSources().filter((s) => s.watched)
const newItems: MediaItem[] = []
for (const src of watched) {
// Cheap freshness check — skip the full re-index when nothing is new.
if (src.feedUrl) {
const known = new Set(listMediaItems(src.id).map((m) => m.videoId))
const recent = await fetchFeedIds(src.feedUrl).catch(() => null)
if (recent && recent.length > 0 && recent.every((id) => known.has(id))) continue
}
const before = new Set(listMediaItems(src.id).map((m) => m.videoId))
const res = await indexSource(src.url, onProgress)
if (res.ok) {
for (const it of listMediaItems(src.id)) {
if (!before.has(it.videoId)) newItems.push(it)
}
}
}
return { ok: true, newItems }
} catch (e) {
return { ok: false, newItems: [], error: e instanceof Error ? e.message : String(e) }
}
}
+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
}
+53 -8
View File
@@ -7,21 +7,27 @@
*/
import { isAbsolute } from 'path'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate } from '@shared/ipc'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem } from '@shared/ipc'
// --- Path-traversal sanitization (audit S4) ---------------------------------
/**
* 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). */
@@ -75,3 +81,42 @@ export function isTemplateLike(o: unknown): o is CommandTemplate {
const t = o as Record<string, unknown>
return typeof t.id === 'string' || typeof t.id === 'number'
}
/** A persisted sources.json row must have the right shape or it's dropped on read. */
export function isValidSource(o: unknown): o is Source {
if (!o || typeof o !== 'object') return false
const s = o as Record<string, unknown>
return (
typeof s.id === 'string' &&
typeof s.url === 'string' &&
(s.kind === 'channel' || s.kind === 'playlist') &&
typeof s.title === 'string' &&
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')
)
}
/** A persisted media-items.json row must have the right shape or it's dropped on read. */
export function isValidMediaItem(o: unknown): o is MediaItem {
if (!o || typeof o !== 'object') return false
const m = o as Record<string, unknown>
return (
typeof m.id === 'string' &&
typeof m.sourceId === 'string' &&
typeof m.videoId === 'string' &&
typeof m.title === 'string' &&
typeof m.url === 'string' &&
typeof m.playlistTitle === 'string' &&
typeof m.playlistIndex === 'number' &&
typeof m.downloaded === 'boolean' &&
isOptionalString(m.durationLabel) &&
isOptionalString(m.filePath) &&
(m.downloadedAt === undefined || typeof m.downloadedAt === 'number')
)
}
+14 -1
View File
@@ -1,7 +1,12 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
import {
isYtdlpUpdateChannel,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult
} from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
@@ -38,6 +43,14 @@ 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.' })
}
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
+73 -1
View File
@@ -1,6 +1,9 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
@@ -17,11 +20,38 @@ import {
type ErrorLogEntry,
type BackupExportResult,
type BackupImportResult,
type SystemThemeInfo
type SystemThemeInfo,
type Source,
type MediaItem,
type IndexProgress,
type IndexSourceResult,
type SyncResult,
type ScheduledSyncStatus
} 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),
@@ -121,6 +151,48 @@ const api = {
const listener = (_e: IpcRendererEvent, url: string): void => cb(url)
ipcRenderer.on(IpcChannels.externalUrl, listener)
return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener)
},
// --- Media-manager sources (Pinchflat-style; see ROADMAP-PINCHFLAT.md) ---
listSources: (): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourcesList),
/** Index (or re-index) a channel/playlist URL into the persisted source list. */
indexSource: (url: string): Promise<IndexSourceResult> =>
ipcRenderer.invoke(IpcChannels.sourceIndex, url),
/** Re-index an existing source by id (refreshes its media-item list). */
reindexSource: (id: string): Promise<IndexSourceResult> =>
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
removeSource: (id: string): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
/** Persist that a media item has finished downloading (drives incremental sync). */
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
/** Toggle whether a source is watched for new uploads. */
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
/** Re-index all watched sources; resolves with the videos found new. */
syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
/** Read / write the Windows daily-sync scheduled task. */
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
ipcRenderer.invoke(IpcChannels.scheduledSyncGet),
setScheduledSync: (enabled: boolean): Promise<ScheduledSyncStatus> =>
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled),
/** Subscribe to live indexing progress. Returns an unsubscribe function. */
onIndexProgress: (cb: (p: IndexProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
ipcRenderer.on(IpcChannels.indexProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
}
}
+28 -6
View File
@@ -1,13 +1,14 @@
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 { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useSystemTheme } from './store/systemTheme'
import { useResolvedDark } from './store/systemTheme'
const useStyles = makeStyles({
provider: {
@@ -31,9 +32,26 @@ 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')
// 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)
@@ -59,13 +77,17 @@ 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 === 'settings' && <SettingsView />}
</main>
+5 -290
View File
@@ -3,8 +3,6 @@ import {
Input,
Button,
Checkbox,
Switch,
Field,
Spinner,
Text,
Caption1,
@@ -16,36 +14,19 @@ import {
import {
ArrowDownloadRegular,
ClipboardPasteRegular,
FolderRegular,
SearchRegular,
VideoClipRegular,
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
DismissRegular,
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular,
CodeRegular,
EyeRegular,
EyeOffRegular,
CopyRegular
AppsListRegular
} 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 { 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 {
@@ -184,33 +165,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',
@@ -258,63 +212,21 @@ const useStyles = makeStyles({
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
folder: {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: tokens.colorNeutralForeground3,
maxWidth: '360px'
},
folderPath: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
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 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
// 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)
// 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
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -393,7 +305,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]
@@ -468,61 +379,6 @@ 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 ?? ''
}
// 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 download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -543,18 +399,10 @@ export function DownloadBar(): React.JSX.Element {
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)
}
setUrl('')
@@ -568,10 +416,7 @@ export function DownloadBar(): React.JSX.Element {
addFromUrl(e.url, kind, quality, {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
durationLabel: e.durationLabel
})
}
setUrl('')
@@ -769,136 +614,6 @@ export function DownloadBar(): React.JSX.Element {
</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>
)
}
@@ -230,6 +230,29 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Caption1>
</div>
)}
<Field
label="Sidecar files"
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
>
<div className={styles.subGroup}>
<Checkbox
checked={value.writeInfoJson}
onChange={(_, d) => setOpt('writeInfoJson', !!d.checked)}
label="Metadata (.info.json)"
/>
<Checkbox
checked={value.writeThumbnailFile}
onChange={(_, d) => setOpt('writeThumbnailFile', !!d.checked)}
label="Thumbnail image file"
/>
<Checkbox
checked={value.writeDescription}
onChange={(_, d) => setOpt('writeDescription', !!d.checked)}
label="Description (.description)"
/>
</div>
</Field>
</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}>
+581
View File
@@ -0,0 +1,581 @@
import { useEffect, useMemo, useState } from 'react'
import {
Subtitle2,
Body1,
Caption1,
Text,
Input,
Button,
Checkbox,
Switch,
Spinner,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
SearchRegular,
ArrowSyncRegular,
ArrowClockwiseRegular,
DeleteRegular,
ArrowDownloadRegular,
ChevronDownRegular,
ChevronRightRegular,
AppsListRegular,
VideoClipMultipleRegular,
AlertRegular,
LibraryRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source } from '@shared/ipc'
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
import { useSettings } from '../store/settings'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
// True in the standalone browser preview (no Electron preload).
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'
const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending',
queued: 'Queued',
downloading: 'Downloading',
completed: 'Downloaded',
error: 'Failed',
canceled: 'Canceled'
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 },
toolbar: {
display: 'flex',
alignItems: 'center',
gap: '14px',
flexWrap: 'wrap',
paddingTop: '2px'
},
toolbarSpacer: { flexGrow: 1 },
switchRow: {
display: 'flex',
alignItems: 'center',
gap: '2px',
color: tokens.colorNeutralForeground2
},
progress: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase200
},
error: { color: tokens.colorPaletteRedForeground1 },
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '56px 16px',
color: tokens.colorNeutralForeground3,
textAlign: 'center'
},
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
card: {
border: `1px solid ${tokens.colorNeutralStroke2}`,
...shorthands.borderRadius(tokens.borderRadiusLarge),
backgroundColor: tokens.colorNeutralBackground1,
overflow: 'hidden'
},
cardHead: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
cursor: 'pointer',
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
srcIcon: {
width: '34px',
height: '34px',
flexShrink: 0,
borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
},
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
watchBadge: {
flexShrink: 0,
fontSize: tokens.fontSizeBase100,
padding: '0 7px',
...shorthands.borderRadius(tokens.borderRadiusCircular),
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
srcSub: { color: tokens.colorNeutralForeground3 },
detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
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
},
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'
},
rowThumb: {
width: '60px',
height: '34px',
...shorthands.borderRadius(tokens.borderRadiusSmall)
},
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
rowTitle: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: tokens.colorNeutralForeground1
},
rowMeta: { color: tokens.colorNeutralForeground3 },
pill: {
flexShrink: 0,
fontSize: tokens.fontSizeBase200,
padding: '1px 8px',
...shorthands.borderRadius(tokens.borderRadiusCircular),
backgroundColor: tokens.colorNeutralBackground3,
color: tokens.colorNeutralForeground3
},
pillDownloading: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
pillCompleted: {
backgroundColor: tokens.colorPaletteGreenBackground2,
color: tokens.colorPaletteGreenForeground2
},
pillError: {
backgroundColor: tokens.colorPaletteRedBackground2,
color: tokens.colorPaletteRedForeground2
}
})
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
const map = new Map<string, MediaItem[]>()
for (const it of items) {
const arr = map.get(it.playlistTitle) ?? []
arr.push(it)
map.set(it.playlistTitle, arr)
}
const groups = [...map.entries()].map(([title, its]) => ({
title,
items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
}))
groups.sort(
(a, b) =>
(a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
a.title.localeCompare(b.title)
)
return groups
}
function relTime(ms?: number): string {
if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago`
return `${Math.round(hrs / 24)} d ago`
}
export function LibraryView(): React.JSX.Element {
const styles = useStyles()
const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId)
const indexing = useSources((s) => s.indexing)
const selectSource = useSources((s) => s.selectSource)
const indexSource = useSources((s) => s.indexSource)
const reindexSource = useSources((s) => s.reindexSource)
const removeSource = useSources((s) => s.removeSource)
const enqueueItems = useSources((s) => s.enqueueItems)
const setWatched = useSources((s) => s.setWatched)
const syncWatched = useSources((s) => s.syncWatched)
const syncing = useSources((s) => s.syncing)
const downloadItems = useDownloads((s) => s.items)
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
const updateSettings = useSettings((s) => s.update)
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [syncNote, setSyncNote] = useState<string | null>(null)
const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length
// Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => {
if (PREVIEW) return
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
}, [])
async function onCheckNew(): Promise<void> {
setSyncNote(null)
const n = await syncWatched()
setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
}
async function toggleScheduled(next: boolean): Promise<void> {
setScheduled(next) // optimistic
if (PREVIEW) return
const res = await window.api.setScheduledSync(next)
setScheduled(res.enabled)
if (res.error) setError(res.error)
}
// Reset the selection whenever the expanded source changes.
useEffect(() => setSelected(new Set()), [selectedSourceId])
// Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => {
const m = new Map<string, DownloadStatus>()
for (const d of downloadItems) m.set(d.url, d.status)
return m
}, [downloadItems])
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
const groups = useMemo(() => groupByPlaylist(items), [items])
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
async function onIndex(): Promise<void> {
setError(null)
const u = url.trim()
if (!u || indexing.active) return
const res = await indexSource(u)
if (res.ok) setUrl('')
else setError(res.error ?? 'Could not index that link.')
}
function toggle(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
for (const it of groupItems) {
if (on) next.add(it.id)
else next.delete(it.id)
}
return next
})
}
function downloadSelected(): void {
if (!selectedSourceId) return
const chosen = items.filter((it) => selected.has(it.id))
enqueueItems(selectedSourceId, chosen)
setSelected(new Set())
}
function downloadPending(): void {
if (!selectedSourceId) return
enqueueItems(selectedSourceId, pendingItems)
}
function pillClass(status: ItemStatus): string {
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
return styles.pill
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Library</Subtitle2>
<Caption1 className={styles.sub}>
Index a channel or playlist once, then download it into organized folders.
</Caption1>
</div>
<div className={styles.addRow}>
<Input
className={styles.addInput}
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL…"
size="large"
contentBefore={<LibraryRegular />}
disabled={indexing.active}
/>
<Button
size="large"
appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={onIndex}
disabled={!url.trim() || indexing.active}
>
Index
</Button>
</div>
<div className={styles.toolbar}>
<Button
size="small"
appearance="secondary"
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />}
onClick={onCheckNew}
disabled={syncing || watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} />
<span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1>
<Switch
checked={autoDownloadNew}
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
aria-label="Auto-download new uploads"
/>
</span>
<span className={styles.switchRow}>
<Caption1>Daily sync</Caption1>
<Switch
checked={scheduled}
onChange={(_, d) => toggleScheduled(d.checked)}
aria-label="Daily background sync"
/>
</span>
</div>
{indexing.active && (
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Indexing…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
</div>
)}
{error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? (
<div className={styles.empty}>
<LibraryRegular fontSize={40} />
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
</div>
) : (
<div className={styles.list}>
{sources.map((src) => (
<SourceCard
key={src.id}
styles={styles}
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() =>
selectSource(selectedSourceId === src.id ? null : src.id)
}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
<Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)}
</Caption1>
<div className={styles.actionSpacer} />
{selected.size > 0 ? (
<>
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
Clear
</Button>
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadSelected}
>
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
</Button>
</>
) : (
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadPending}
disabled={pendingItems.length === 0}
>
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
</Button>
)}
<span className={styles.switchRow}>
<Caption1>Watch</Caption1>
<Switch
checked={!!src.watched}
onChange={(_, d) => setWatched(src.id, !!d.checked)}
aria-label={`Watch ${src.title} for new uploads`}
/>
</span>
<Button
size="small"
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() => reindexSource(src.id)}
disabled={indexing.active}
>
Re-index
</Button>
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => removeSource(src.id)}
>
Remove
</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}`}
/>
<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>
)
})}
</div>
</div>
)
})}
</div>
</SourceCard>
))}
</div>
)}
</div>
)
}
/** One collapsible source card (header always shown; children render when expanded). */
function SourceCard({
styles,
source,
expanded,
onToggleExpand,
children
}: {
styles: ReturnType<typeof useStyles>
source: Source
expanded: boolean
onToggleExpand: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<div className={styles.card}>
<div
className={styles.cardHead}
onClick={onToggleExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
aria-expanded={expanded}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<span className={styles.srcTitle}>{source.title}</span>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={styles.srcSub}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
</Caption1>
</div>
</div>
{expanded && children}
</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}>
+8 -13
View File
@@ -15,15 +15,13 @@ import {
OpenRegular,
FolderRegular,
ArrowClockwiseRegular,
VideoClipRegular,
MusicNote2Regular,
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({
@@ -103,8 +101,6 @@ function pct(progress: number): string {
export 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 remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
@@ -124,13 +120,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}>
+169 -6
View File
@@ -10,6 +10,7 @@ import {
Caption1,
Spinner,
Text,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
@@ -31,13 +32,16 @@ import {
CopyRegular,
DeleteRegular,
PaintBucketRegular,
AccessibilityRegular
AccessibilityRegular,
ArrowSyncRegular,
OpenRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type AppUpdateInfo,
type MediaKind,
type CookieSource,
type CookieBrowser,
@@ -71,6 +75,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 +185,10 @@ 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)
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)
@@ -207,6 +224,14 @@ export function SettingsView(): React.JSX.Element {
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 +246,49 @@ 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))
}, [])
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)
@@ -328,17 +396,49 @@ export function SettingsView(): React.JSX.Element {
<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>
@@ -731,6 +831,69 @@ 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} />
+189 -43
View File
@@ -1,21 +1,20 @@
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,
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' | 'history' | 'settings'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
const useStyles = makeStyles({
root: {
@@ -26,13 +25,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',
@@ -63,7 +97,8 @@ const useStyles = makeStyles({
nav: {
display: 'flex',
flexDirection: 'column',
gap: '3px'
gap: '3px',
alignSelf: 'stretch'
},
navItem: {
display: 'flex',
@@ -83,6 +118,10 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
navItemCollapsed: {
justifyContent: 'center',
padding: '9px 0'
},
navItemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
@@ -93,92 +132,199 @@ 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
}
}
})
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: '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>
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
</div>
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<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" />
</div>
{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>
)
}
+78 -4
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)',
@@ -31,6 +32,7 @@ if (import.meta.env.DEV && !window.api) {
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: true
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
@@ -43,6 +45,27 @@ 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)' }),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
@@ -120,11 +143,11 @@ 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) => {
@@ -139,7 +162,58 @@ if (import.meta.env.DEV && !window.api) {
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {}
onExternalUrl: () => () => {},
// Media-manager sources — a seeded channel so the (Phase H) Library view has
// demo data in this browser-only preview.
listSources: async () => [
{
id: 'src-demo',
url: 'https://www.youtube.com/@DevChannel',
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now() - 86_400_000,
lastIndexedAt: Date.now() - 3_600_000,
itemCount: 7
}
],
indexSource: async (url) => {
await new Promise((r) => setTimeout(r, 800)) // simulate the index walk
return {
ok: true,
source: {
id: 'src-demo',
url,
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now(),
lastIndexedAt: Date.now(),
itemCount: 7
},
itemCount: 7
}
},
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
removeSource: async () => [],
markSourceItemDownloaded: async () => [],
setSourceWatched: async () => [],
syncSources: async () => ({ ok: true, newItems: [] }),
getScheduledSync: async () => ({ enabled: false }),
setScheduledSync: async (enabled: boolean) => ({ enabled }),
listSourceItems: async (sourceId) =>
Array.from({ length: 7 }, (_, i) => ({
id: `${sourceId}:vid${i + 1}`,
sourceId,
videoId: `vid${i + 1}`,
title: `Episode ${i + 1}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
playlistTitle: i < 5 ? 'Full Electron Course' : 'Uploads',
playlistIndex: i < 5 ? i + 1 : i - 4,
durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2
})),
onIndexProgress: () => () => {}
}
}
+23 -3
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand'
import type { DownloadEvent, DownloadMeta, DownloadOptions } from '@shared/ipc'
import type { DownloadEvent, DownloadMeta, DownloadOptions, CollectionContext } from '@shared/ipc'
import { useSettings } from './settings'
import { useHistory } from './history'
import { useSources } from './sources'
export type MediaKind = 'video' | 'audio'
@@ -42,6 +43,10 @@ export interface DownloadItem {
incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
probedMeta?: DownloadMeta
/** media-manager folder context — files this into <channel>/<playlist>/<NNN> - <title> */
collection?: CollectionContext
/** when set, the source MediaItem id this download came from — marked downloaded on completion */
mediaItemId?: string
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
@@ -68,6 +73,8 @@ interface DownloadState {
options?: DownloadOptions
extraArgs?: string
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
}
) => void
retry: (id: string) => void
@@ -210,6 +217,11 @@ function startFakeTicker(
completedAt: Date.now()
})
}
// Mirror the real applyEvent path so collection completions mark their
// source item downloaded in the preview too.
if (finished?.mediaItemId) {
useSources.getState().markDownloaded(finished.mediaItemId, finished.filePath)
}
get().pump() // a slot just freed — promote the next queued item
}
}, 650)
@@ -235,12 +247,14 @@ 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,
meta: item.probedMeta
meta: item.probedMeta,
collection: item.collection
})
.then((res) => {
if (!res.ok) markError(item.id, res.error)
@@ -301,6 +315,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
probedMeta
}
set((s) => ({ items: [item, ...s.items] }))
@@ -411,6 +427,10 @@ export const useDownloads = create<DownloadState>((set, get) => {
completedAt: Date.now()
})
}
// Persist media-manager completion so an incremental re-sync skips it.
if (item?.mediaItemId) {
useSources.getState().markDownloaded(item.mediaItemId, item.filePath)
}
}
// A finished item frees a slot — promote whatever is queued next.
if (ev.type === 'done' || ev.type === 'error') pump()
+12 -5
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)',
@@ -25,6 +26,7 @@ const FALLBACK: Settings = {
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
@@ -34,7 +36,10 @@ 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) => ({
@@ -46,12 +51,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.
+288
View File
@@ -0,0 +1,288 @@
import { create } from 'zustand'
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
import { useDownloads } from './downloads'
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[] {
return Array.from({ length: n }, (_, i) => ({
id: `${sourceId}:${playlist}-${i + 1}`,
sourceId,
videoId: `${playlist}-${i + 1}`,
title: `${playlist} — part ${i + 1}`,
url: `https://youtube.com/watch?v=${sourceId}${i + 1}`,
playlistTitle: playlist,
playlistIndex: i + 1,
durationLabel: `${10 + i}:${String(i * 7).padStart(2, '0')}`,
downloaded: i < downloadedUpTo
}))
}
const seedSources: Source[] = PREVIEW
? [
{
id: 'src-dev',
url: 'https://www.youtube.com/@DevChannel',
kind: 'channel',
title: 'DevChannel',
channel: 'DevChannel',
addedAt: Date.now() - 86_400_000,
lastIndexedAt: Date.now() - 3_600_000,
itemCount: 9
},
{
id: 'src-mix',
url: 'https://www.youtube.com/playlist?list=PLmix',
kind: 'playlist',
title: 'Lo-fi Coding Mixes',
channel: 'ChillStudio',
addedAt: Date.now() - 2 * 86_400_000,
lastIndexedAt: Date.now() - 7_200_000,
itemCount: 5
}
]
: []
const seedItemsBySource: Record<string, MediaItem[]> = PREVIEW
? {
'src-dev': [
...seedItems('src-dev', 'Full Electron Course', 6, 2),
...seedItems('src-dev', 'Uploads', 3)
],
'src-mix': seedItems('src-mix', 'Lo-fi Coding Mixes', 5, 1)
}
: {}
// --- Store ------------------------------------------------------------------
/** Live indexing status for the add-source bar. */
interface IndexingState {
active: boolean
url?: string
message?: string
current?: number
total?: number
}
interface SourcesState {
sources: Source[]
/** media items per source, lazily loaded when a source is expanded */
itemsBySource: Record<string, MediaItem[]>
/** which source is expanded in the library view (null = none) */
selectedSourceId: string | null
indexing: IndexingState
loadSources: () => void
selectSource: (id: string | null) => void
/** index a pasted channel/playlist URL; resolves with ok + an error message */
indexSource: (url: string) => Promise<{ ok: boolean; error?: string }>
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.
*/
enqueueItems: (sourceId: string, items: MediaItem[]) => number
/** mark an item downloaded once its queue download completes (called by the downloads store) */
markDownloaded: (itemId: string, filePath?: string) => void
/** toggle a source's watched-for-new-uploads flag (Phase J) */
setWatched: (id: string, watched: boolean) => void
/** whether a sync is currently running (the "Check for new" button's busy state) */
syncing: boolean
/**
* Re-index all watched sources and (when autoDownloadNew is on) enqueue the new
* videos. Resolves with how many new videos were found.
*/
syncWatched: () => Promise<number>
}
export const useSources = create<SourcesState>((set, get) => ({
sources: seedSources,
itemsBySource: seedItemsBySource,
selectedSourceId: null,
indexing: { active: false },
syncing: false,
loadSources: () => {
if (PREVIEW) return
window.api
.listSources()
.then((sources) => set({ sources }))
.catch(() => {})
},
selectSource: (id) => {
set({ selectedSourceId: id })
// Lazily fetch a source's items the first time it's expanded.
if (id && !get().itemsBySource[id] && !PREVIEW) {
window.api
.listSourceItems(id)
.then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
.catch(() => {})
}
},
indexSource: async (url) => {
set({ indexing: { active: true, url, message: 'Reading source…' } })
if (PREVIEW) {
await new Promise((r) => setTimeout(r, 900)) // simulate the index walk
set({ indexing: { active: false } })
return { ok: true }
}
try {
const res = await window.api.indexSource(url)
set({ indexing: { active: false } })
if (res.ok) {
get().loadSources()
if (res.source) get().selectSource(res.source.id)
}
return { ok: res.ok, error: res.error }
} catch (e) {
set({ indexing: { active: false } })
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
},
reindexSource: async (id) => {
const src = get().sources.find((s) => s.id === id)
set({ indexing: { active: true, url: src?.url, message: 'Re-indexing…' } })
if (PREVIEW) {
await new Promise((r) => setTimeout(r, 700))
set({ indexing: { active: false } })
return
}
try {
const res = await window.api.reindexSource(id)
set({ indexing: { active: false } })
if (res.ok) {
get().loadSources()
const items = await window.api.listSourceItems(id)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
}
} catch {
set({ indexing: { active: false } })
}
},
removeSource: (id) => {
set((s) => ({
sources: s.sources.filter((x) => x.id !== id),
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
}))
if (!PREVIEW) window.api.removeSource(id).catch(() => {})
},
enqueueItems: (sourceId, items) => {
const src = get().sources.find((s) => s.id === sourceId)
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, {
title: it.title,
channel: src?.channel,
durationLabel: it.durationLabel,
collection: {
channel: src?.channel || src?.title || 'Channel',
playlist: it.playlistTitle,
index: it.playlistIndex
},
mediaItemId: it.id
})
}
return batch.length
},
markDownloaded: (itemId, filePath) => {
set((s) => {
const next: Record<string, MediaItem[]> = {}
let changed = false
for (const [sid, list] of Object.entries(s.itemsBySource)) {
if (list.some((m) => m.id === itemId)) {
changed = true
next[sid] = list.map((m) =>
m.id === itemId ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
)
} else {
next[sid] = list
}
}
return changed ? { itemsBySource: next } : {}
})
if (!PREVIEW) window.api.markSourceItemDownloaded(itemId, filePath).catch(() => {})
},
setWatched: (id, watched) => {
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(() => {})
},
syncWatched: async () => {
if (get().syncing) return 0
set({ syncing: true })
try {
if (PREVIEW) {
await new Promise((r) => setTimeout(r, 700))
return 0 // preview has no real feed to poll
}
const res = await window.api.syncSources()
if (!res.ok) return 0
get().loadSources()
// Refresh any expanded source's items so new videos appear in the tree.
const sel = get().selectedSourceId
if (sel) {
const items = await window.api.listSourceItems(sel)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [sel]: items } }))
}
// Auto-enqueue the new videos, grouped by source, when the setting is on.
if (useSettings.getState().autoDownloadNew && res.newItems.length > 0) {
const bySource = new Map<string, MediaItem[]>()
for (const it of res.newItems) {
const arr = bySource.get(it.sourceId) ?? []
arr.push(it)
bySource.set(it.sourceId, arr)
}
for (const [sid, items] of bySource) get().enqueueItems(sid, items)
}
return res.newItems.length
} finally {
set({ syncing: false })
}
}
}))
// Load persisted sources on startup, and subscribe to live indexing progress.
if (!PREVIEW) {
window.api
.listSources()
.then((sources) => {
useSources.setState({ sources })
// If any source is watched, kick off a sync shortly after launch (covers the
// scheduled `--sync` launch and a normal launch alike).
if (sources.some((s) => s.watched)) {
setTimeout(() => useSources.getState().syncWatched(), 1500)
}
})
.catch(() => {})
window.api.onIndexProgress((p: IndexProgress) => {
useSources.setState({
indexing: {
active: p.phase !== 'done' && p.phase !== 'error',
url: p.url,
message: p.message,
current: p.current,
total: p.total
}
})
})
}
+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
}
+217 -6
View File
@@ -4,6 +4,16 @@
*/
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',
probe: 'media:probe',
downloadStart: 'download:start',
@@ -40,7 +50,24 @@ export const IpcChannels = {
openHighContrastSettings: 'shell:open-high-contrast-settings',
/** main → renderer push channel: a URL handed to AeroFetch from outside the app
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
externalUrl: 'external-url'
externalUrl: 'external-url',
// --- Sources & media-manager indexing (Pinchflat-style, see ROADMAP-PINCHFLAT.md) ---
sourcesList: 'sources:list',
sourceIndex: 'sources:index',
sourceReindex: 'sources:reindex',
sourceRemove: 'sources:remove',
sourceItems: 'sources:items',
/** mark a media item downloaded once its collection download completes */
sourceItemDownloaded: 'sources:item-downloaded',
/** toggle whether a source is watched for new uploads (Phase J) */
sourceSetWatched: 'sources:set-watched',
/** re-index all watched sources and return the videos that are new */
sourcesSync: 'sources:sync',
/** get / set the Windows scheduled-sync task (Task Scheduler) */
scheduledSyncGet: 'sources:scheduled-sync-get',
scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -134,6 +161,12 @@ export interface DownloadOptions {
embedThumbnail: boolean
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
cropThumbnail: boolean
/** write a .info.json metadata sidecar (Jellyfin/Plex/Kodi ingest — Phase K) */
writeInfoJson: boolean
/** write the thumbnail as a separate image file alongside the video */
writeThumbnailFile: boolean
/** write the video description to a .description sidecar file */
writeDescription: boolean
}
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
@@ -149,7 +182,10 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
embedChapters: false,
embedMetadata: true,
embedThumbnail: true,
cropThumbnail: false
cropThumbnail: false,
writeInfoJson: false,
writeThumbnailFile: false,
writeDescription: false
}
export interface YtdlpVersionResult {
@@ -160,8 +196,62 @@ export interface YtdlpVersionResult {
error?: string
}
/** yt-dlp's self-update release channel (`--update-to <channel>`). */
export type YtdlpUpdateChannel = 'stable' | 'nightly'
/** 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
@@ -225,6 +315,24 @@ export interface ProbeResult {
error?: string
}
/**
* Media-manager folder context for a collection download (see ROADMAP-PINCHFLAT.md
* Phase G). When present on a StartDownloadOptions, the file is filed into
* `<outputDir>/<channel>/<playlist>/<NNN> - <title>.<ext>` instead of the flat
* filenameTemplate. The directory segments are sanitized for Windows at argv-build
* time, and `index` (the 1-based playlist position from the persisted MediaItem)
* drives the NNN prefix — not yt-dlp's %(playlist_index)s, which is empty under
* the per-video `--no-playlist` path these downloads still take.
*/
export interface CollectionContext {
/** channel / uploader name — the top folder level */
channel: string
/** playlist title — the second folder level ('Uploads' for the catch-all) */
playlist: string
/** 1-based position within the playlist, for the NNN filename prefix */
index: number
}
/** Sent renderer → main to kick off a download. The renderer owns the id. */
export interface StartDownloadOptions {
id: string
@@ -255,6 +363,12 @@ export interface StartDownloadOptions {
* race where the late probe overwrites a good title the renderer supplied.
*/
meta?: DownloadMeta
/**
* When set, this is a media-manager (collection) download — file it into
* `<channel>/<playlist>/<NNN> - <title>` folders instead of the flat
* filenameTemplate. See CollectionContext.
*/
collection?: CollectionContext
}
export interface StartDownloadResult {
@@ -290,8 +404,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
@@ -327,6 +443,8 @@ export interface Settings {
defaultTemplateId: string | null
/** show a native OS notification when a download finishes or fails */
notifyOnComplete: boolean
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
autoDownloadNew: boolean
/** false until the first-run welcome screen has been dismissed */
hasCompletedOnboarding: boolean
}
@@ -410,3 +528,96 @@ export interface BackupImportResult {
ok: boolean
error?: string
}
// --- Sources & media-manager indexing (Pinchflat-style) ---------------------
// See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a
// persisted list of MediaItem records; the live download queue then pulls from
// that list a batch at a time, so "download an entire channel" never has to hold
// the whole collection in the queue at once.
/** Whether a Source is a whole channel or a single playlist. */
export type SourceKind = 'channel' | 'playlist'
/**
* A monitored channel or playlist that AeroFetch has indexed. Its full video
* list lives as MediaItem records (one JSON store), keyed back by `id`.
*/
export interface Source {
id: string
/** the URL the user added (used for re-indexing) */
url: string
kind: SourceKind
title: string
/** channel / uploader name, when known */
channel?: string
/** epoch ms the source was first added */
addedAt: number
/** epoch ms of the last successful (re)index; undefined if never indexed */
lastIndexedAt?: number
/** number of MediaItems at the last index (cached for list display) */
itemCount: number
/** watched for new uploads — included in scheduled / startup sync (Phase J) */
watched?: boolean
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
feedUrl?: string
}
/**
* One video discovered while indexing a Source. `playlistTitle`/`playlistIndex`
* drive the on-disk folder layout (<Channel>/<Playlist>/<NNN> - <Title>); the
* `downloaded` flag + `filePath` let the library view show per-item state and
* let re-syncs skip what's already on disk.
*/
export interface MediaItem {
/** globally unique, formed as `${sourceId}:${videoId}` */
id: string
sourceId: string
/** the yt-dlp video id — the dedup key within a source */
videoId: string
title: string
url: string
/** the playlist this item is filed under; 'Uploads' for videos in no playlist */
playlistTitle: string
/** 1-based position within its playlist, for NNN numbering */
playlistIndex: number
durationLabel?: string
downloaded: boolean
downloadedAt?: number
filePath?: string
}
/** Live progress pushed while a Source is being indexed (main → renderer). */
export interface IndexProgress {
/** the Source URL being indexed (correlates events back to the request) */
url: string
phase: 'start' | 'playlists' | 'playlist' | 'uploads' | 'done' | 'error'
/** human-readable status line */
message: string
/** for the per-playlist phase: playlists processed so far / total */
current?: number
total?: number
}
/** Result of indexing (or re-indexing) a Source. */
export interface IndexSourceResult {
ok: boolean
source?: Source
itemCount?: number
/** how many videos were new since the previous index (all of them on first index) */
newCount?: number
error?: string
}
/** Result of syncing all watched sources: the videos found new across them. */
export interface SyncResult {
ok: boolean
/** newly-discovered media items across all watched sources (empty if nothing new) */
newItems: MediaItem[]
error?: string
}
/** Whether the Windows scheduled-sync task is currently registered. */
export interface ScheduledSyncStatus {
enabled: boolean
error?: string
}
+173
View File
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
selectExtraArgs,
formatCommandLine,
sanitizeDirSegment,
collectionOutputTemplate,
CROP_SQUARE_PPA,
ARIA2C_ARGS,
type AccessOptions
@@ -326,6 +329,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(
@@ -360,3 +437,99 @@ describe('buildArgs extraArgs', () => {
expect(withEmpty).toEqual(withoutParam)
})
})
// --- Media-server sidecar files (Phase K) -----------------------------------
describe('sidecar files', () => {
it('emits --write-info-json / --write-thumbnail / --write-description when enabled', () => {
const argv = build(
opts(),
dlo({ writeInfoJson: true, writeThumbnailFile: true, writeDescription: true })
)
expect(argv).toContain('--write-info-json')
expect(argv).toContain('--write-thumbnail')
expect(argv).toContain('--write-description')
})
it('emits none of them by default', () => {
const argv = build(opts(), dlo())
expect(argv).not.toContain('--write-info-json')
expect(argv).not.toContain('--write-description')
// (--write-thumbnail is sidecar-only here; embedThumbnail uses --embed-thumbnail)
expect(argv).not.toContain('--write-thumbnail')
})
})
// --- Collection folder paths (Phase G, media-manager) -----------------------
describe('sanitizeDirSegment', () => {
it('keeps an ordinary name (incl. spaces and hyphens) intact', () => {
expect(sanitizeDirSegment('Linus Tech Tips')).toBe('Linus Tech Tips')
expect(sanitizeDirSegment('Two-Minute Papers')).toBe('Two-Minute Papers')
})
it('replaces Windows-illegal characters with a space and collapses runs', () => {
expect(sanitizeDirSegment('A/B\\C:D*E?F')).toBe('A B C D E F')
expect(sanitizeDirSegment('Best of 2024 <Live>')).toBe('Best of 2024 Live')
})
it('strips leading/trailing dots and spaces, neutralising `..` traversal', () => {
expect(sanitizeDirSegment('..')).toBe('Untitled')
expect(sanitizeDirSegment('../../etc')).toBe('etc')
expect(sanitizeDirSegment(' trailing. ')).toBe('trailing')
expect(sanitizeDirSegment('My Playlist.')).toBe('My Playlist')
})
it('prefixes reserved Windows device names', () => {
expect(sanitizeDirSegment('CON')).toBe('_CON')
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')
})
it('caps length to keep the overall path bounded', () => {
expect(sanitizeDirSegment('x'.repeat(200)).length).toBe(80)
})
})
describe('collectionOutputTemplate', () => {
const ctx = { channel: 'DevChannel', playlist: 'Full Course', index: 3 }
it('files into <out>/<channel>/<playlist>/<NNN> - <filename>', () => {
const t = collectionOutputTemplate('C:/out', ctx, '%(title)s.%(ext)s')
// normalise separators so the assertion is OS-independent
expect(t.replace(/\\/g, '/')).toBe('C:/out/DevChannel/Full Course/003 - %(title)s.%(ext)s')
})
it('zero-pads the index to three digits and clamps bad values to 001', () => {
expect(collectionOutputTemplate('C:/o', { ...ctx, index: 42 }, 'f').replace(/\\/g, '/')).toContain(
'/042 - f'
)
expect(collectionOutputTemplate('C:/o', { ...ctx, index: 0 }, 'f').replace(/\\/g, '/')).toContain(
'/001 - f'
)
expect(
collectionOutputTemplate('C:/o', { ...ctx, index: NaN }, 'f').replace(/\\/g, '/')
).toContain('/001 - f')
})
it('sanitizes the channel and playlist folder names', () => {
const t = collectionOutputTemplate(
'C:/out',
{ channel: 'A/B', playlist: '..', index: 1 },
'%(title)s.%(ext)s'
)
expect(t.replace(/\\/g, '/')).toBe('C:/out/A B/Untitled/001 - %(title)s.%(ext)s')
})
})
+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', () => {
+227
View File
@@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import {
classifySource,
buildMediaItems,
mergeItemsPreservingState,
buildFeedUrl,
isYouTubeFeedUrl,
parseRssVideoIds,
entryUrl,
fmtDuration,
stripTabSuffix,
stableSourceId,
type RawEntry
} from '../src/main/indexerCore'
import type { MediaItem } from '@shared/ipc'
describe('classifySource', () => {
it('classifies channel handle / id / c / user forms and strips the tab', () => {
expect(classifySource('https://www.youtube.com/@LinusTechTips')).toEqual({
kind: 'channel',
base: 'https://www.youtube.com/@LinusTechTips'
})
expect(classifySource('https://youtube.com/@Foo/videos')).toEqual({
kind: 'channel',
base: 'https://www.youtube.com/@Foo'
})
expect(classifySource('https://www.youtube.com/channel/UC123/playlists')?.base).toBe(
'https://www.youtube.com/channel/UC123'
)
expect(classifySource('https://www.youtube.com/c/SomeName')?.kind).toBe('channel')
expect(classifySource('https://www.youtube.com/user/Legacy')?.kind).toBe('channel')
})
it('classifies a playlist by its list= param and canonicalises it', () => {
expect(classifySource('https://www.youtube.com/playlist?list=PL_abc')).toEqual({
kind: 'playlist',
base: 'https://www.youtube.com/playlist?list=PL_abc'
})
// a watch URL that also carries list= is treated as the playlist
expect(classifySource('https://www.youtube.com/watch?v=xyz&list=PLfoo')?.kind).toBe('playlist')
})
it('returns null for a lone video, a bad URL, or a non-YouTube host', () => {
expect(classifySource('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull()
expect(classifySource('not a url')).toBeNull()
expect(classifySource('ftp://example.com/@chan')).toBeNull()
expect(classifySource('https://vimeo.com/channels/staffpicks')).toBeNull()
})
})
describe('entryUrl', () => {
it('prefers an explicit http(s) url, else builds a watch url from the id', () => {
expect(entryUrl({ url: 'https://youtu.be/abc' })).toBe('https://youtu.be/abc')
expect(entryUrl({ webpage_url: 'https://x/y' })).toBe('https://x/y')
expect(entryUrl({ id: 'vid123' })).toBe('https://www.youtube.com/watch?v=vid123')
})
it('rejects a non-http url and returns null when nothing usable is present', () => {
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', () => {
it('formats seconds as M:SS or H:MM:SS', () => {
expect(fmtDuration(0)).toBe('0:00')
expect(fmtDuration(75)).toBe('1:15')
expect(fmtDuration(3661)).toBe('1:01:01')
expect(fmtDuration(undefined)).toBeUndefined()
expect(fmtDuration(NaN)).toBeUndefined()
})
})
describe('stripTabSuffix', () => {
it('strips a trailing " - <Tab>" suffix only', () => {
expect(stripTabSuffix('Creator - Videos')).toBe('Creator')
expect(stripTabSuffix('Creator - Playlists')).toBe('Creator')
expect(stripTabSuffix('Just A Name')).toBe('Just A Name')
expect(stripTabSuffix('Tech - Tips')).toBe('Tech - Tips') // 'Tips' isn't a tab
expect(stripTabSuffix(undefined)).toBeUndefined()
})
})
describe('stableSourceId', () => {
it('is deterministic and 8 hex chars', () => {
const a = stableSourceId('https://www.youtube.com/@Foo')
expect(a).toMatch(/^[0-9a-f]{8}$/)
expect(stableSourceId('https://www.youtube.com/@Foo')).toBe(a)
expect(stableSourceId('https://www.youtube.com/@Bar')).not.toBe(a)
})
})
describe('buildMediaItems', () => {
const vids = (...ids: string[]): RawEntry[] => ids.map((id) => ({ id, title: `T-${id}` }))
it('files videos under their playlist with 1-based per-playlist index', () => {
const items = buildMediaItems('src1', [{ title: 'Series A', entries: vids('a', 'b') }], [])
expect(items).toHaveLength(2)
expect(items[0]).toMatchObject({
id: 'src1:a',
sourceId: 'src1',
videoId: 'a',
playlistTitle: 'Series A',
playlistIndex: 1,
downloaded: false
})
expect(items[1]).toMatchObject({ videoId: 'b', playlistIndex: 2 })
})
it('dedups across playlists — the first playlist a video appears in wins', () => {
const items = buildMediaItems(
'src1',
[
{ title: 'First', entries: vids('x', 'y') },
{ title: 'Second', entries: vids('y', 'z') }
],
[]
)
expect(items.map((i) => i.videoId)).toEqual(['x', 'y', 'z'])
expect(items.find((i) => i.videoId === 'y')?.playlistTitle).toBe('First')
})
it('falls back to the synthetic Uploads folder for videos in no playlist', () => {
const items = buildMediaItems('src1', [{ title: 'P', entries: vids('a') }], vids('a', 'b'))
// 'a' already filed under P; only 'b' becomes an Uploads item
expect(items).toHaveLength(2)
expect(items.find((i) => i.videoId === 'a')?.playlistTitle).toBe('P')
const b = items.find((i) => i.videoId === 'b')
expect(b).toMatchObject({ playlistTitle: 'Uploads', playlistIndex: 2 })
})
it('skips entries with no id or no resolvable URL', () => {
const items = buildMediaItems('src1', [{ title: 'P', entries: [{ title: 'no id' }] }], [])
expect(items).toHaveLength(0)
})
})
describe('mergeItemsPreservingState', () => {
const item = (videoId: string, over: Partial<MediaItem> = {}): MediaItem => ({
id: `s:${videoId}`,
sourceId: 's',
videoId,
title: videoId,
url: `https://youtube.com/watch?v=${videoId}`,
playlistTitle: 'P',
playlistIndex: 1,
downloaded: false,
...over
})
it('carries downloaded state forward for videos already on disk', () => {
const existing = [item('a', { downloaded: true, downloadedAt: 111, filePath: 'C:/a.mp4' })]
const fresh = [item('a'), item('b')]
const { items, newCount } = mergeItemsPreservingState(existing, fresh)
expect(newCount).toBe(1) // only 'b' is new
expect(items.find((i) => i.videoId === 'a')).toMatchObject({
downloaded: true,
downloadedAt: 111,
filePath: 'C:/a.mp4'
})
expect(items.find((i) => i.videoId === 'b')?.downloaded).toBe(false)
})
it('adopts the fresh membership/order and drops vanished videos', () => {
const existing = [item('a', { downloaded: true }), item('gone', { downloaded: true })]
const fresh = [item('a', { playlistTitle: 'Moved', playlistIndex: 5 })]
const { items, newCount } = mergeItemsPreservingState(existing, fresh)
expect(items).toHaveLength(1) // 'gone' dropped
expect(newCount).toBe(0)
// fresh playlist assignment wins, but downloaded state is preserved
expect(items[0]).toMatchObject({ playlistTitle: 'Moved', playlistIndex: 5, downloaded: true })
})
it('counts every video as new on a first index (empty existing)', () => {
const { newCount } = mergeItemsPreservingState([], [item('a'), item('b'), item('c')])
expect(newCount).toBe(3)
})
})
describe('buildFeedUrl', () => {
it('builds a channel feed by channel_id and a playlist feed by playlist_id', () => {
expect(buildFeedUrl('channel', 'UCabc')).toBe(
'https://www.youtube.com/feeds/videos.xml?channel_id=UCabc'
)
expect(buildFeedUrl('playlist', 'PLxyz')).toBe(
'https://www.youtube.com/feeds/videos.xml?playlist_id=PLxyz'
)
})
it('returns undefined when the id is missing', () => {
expect(buildFeedUrl('channel', undefined)).toBeUndefined()
})
})
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 = `
<feed><entry><yt:videoId>aaa111</yt:videoId></entry>
<entry><yt:videoId> bbb222 </yt:videoId></entry></feed>`
expect(parseRssVideoIds(xml)).toEqual(['aaa111', 'bbb222'])
})
it('returns an empty array for a feed with no entries', () => {
expect(parseRssVideoIds('<feed></feed>')).toEqual([])
})
})
+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)
})
})