Merge audit chain (Batches 6-15) into main

Reconciles two lines of history that both descended from the same
feat/tray-background-clipboard branch (background running, clipboard-link
detection, updater auth): main picked it up as a single squash commit
(PR #7, d112bc4), while the audit chain (p1-docs..batch-15) branched off
that feature branch's pre-squash tip and continued with its own granular
history, including the H1 decomposition of DownloadBar.tsx/SettingsView.tsx.

12 files conflicted, all resolved by verifying the audit chain already
carries PR #7's functionality (confirmed per-file against the original
d112bc4 diff) and keeping the chain's newer/relocated version:
- shared/ipc.ts, main/settings.ts: DEFAULT_SETTINGS single-sourced (C1)
- main/download.ts, main/tray.ts: trivial, chain is a superset
- main/index.ts: ipcMain.handle wiring extracted to main/ipc.ts (L2);
  removed a silent duplicate notifyBackgroundOnce that 3-way merge
  produced with no conflict markers
- renderer/main.tsx, store/settings.ts: mock/fallback settings
  single-sourced (C1/mockApi.ts)
- LibraryView.tsx: old inline clipboard-suggestion banner replaced by
  the shared LinkSuggestion primitive (Batch 9/UI19); removed dead
  icon imports + styles left behind by the auto-merge
- DownloadBar.tsx, SettingsView.tsx: taken wholesale from the chain
  (H1-decomposed shells) after confirming their extracted hook/cards
  already contain PR #7's additions (useClipboardLink.offer(),
  launchAtStartup, updateToken)
- useClipboardLink.ts, test/clipboardLink.test.ts (add/add): chain's
  version is a strict refinement of the same code (L40/L138/L145)

Verified post-merge: typecheck (node+web), 279 tests, eslint, and the
production build all pass; touched files are prettier-clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:35:40 -04:00
149 changed files with 13440 additions and 4769 deletions
+3
View File
@@ -10,3 +10,6 @@ dist
# Secrets — never commit # Secrets — never commit
.gitea-token .gitea-token
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
.vscode/settings.json
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
View File
+2364 -281
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -109,19 +109,26 @@ yet. This is the prerequisite for every later phase.
channel?: string channel?: string
addedAt: number addedAt: number
lastIndexedAt?: number lastIndexedAt?: number
itemCount: number // cached MediaItem count for list display
watched?: boolean // included in scheduled / startup sync (Phase J)
feedUrl?: string // YouTube RSS feed for cheap "anything new?" checks
} }
interface MediaItem { interface MediaItem {
id: string // yt-dlp video id — the dedup key id: string // globally unique, `${sourceId}:${videoId}`
sourceId: string sourceId: string
videoId: string // yt-dlp video id — the dedup key within a source
title: string title: string
playlistTitle?: string // for the folder path; 'Uploads' fallback url: string
playlistIndex?: number // 1-based, for NNN numbering playlistTitle: string // for the folder path; 'Uploads' fallback
playlistIndex: number // 1-based, for NNN numbering
durationLabel?: string durationLabel?: string
downloaded: boolean downloaded: boolean
downloadedAt?: number downloadedAt?: number
filePath?: string filePath?: string
} }
``` ```
*(This block reflects the shipped `src/shared/ipc.ts` shape; the original sketch
predated the `videoId`/`itemCount`/`watched`/`feedUrl` fields.)*
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem` - [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
(keyed by video id); first playlist seen wins the folder assignment (surface this choice (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 in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
+45 -35
View File
@@ -32,9 +32,10 @@ filename template · yt-dlp version check · NSIS installer + portable build.
A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing
choices end-to-end. Persisted defaults are editable in **Settings → Format & choices end-to-end. Persisted defaults are editable in **Settings → Format &
post-processing** via a reusable `DownloadOptionsForm` component, which the download post-processing** via a reusable `DownloadOptionsForm` component. (The per-download
bar also hosts in a collapsible per-download **Options** panel. The main process emits **Options** panel in the download bar is plumbed end-to-end — `StartDownloadOptions.options`/
the flags in `buildArgs` (`src/main/download.ts`). All items verified in the UI preview `extraArgs` — but not yet surfaced in the bar UI; see audit UX1/M5.) The main process emits
the flags in `buildArgs` (`src/main/buildArgs.ts`). All items verified in the UI preview
(typecheck clean). **Real-download smoke test ✅ (2026-06-23):** crop-to-square cover, (typecheck clean). **Real-download smoke test ✅ (2026-06-23):** crop-to-square cover,
audio re-encode (opus), video container + codec preference (mkv + vp9 merge), subtitle audio re-encode (opus), video container + codec preference (mkv + vp9 merge), subtitle
embed (→ mov_text), chapter embed, and SponsorBlock-remove (output duration shrinks by the embed (→ mov_text), chapter embed, and SponsorBlock-remove (output duration shrinks by the
@@ -55,7 +56,7 @@ found and fixed a real shipping bug — the bundled `ffprobe.exe` was missing (s
- [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) + - [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) +
preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without
overriding the requested resolution. overriding the requested resolution.
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` still TODO.) - [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` shipped in Phase L.)
- [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail - [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail
post-processor args. *Smoke-tested ✅: the embedded cover comes out a perfect square post-processor args. *Smoke-tested ✅: the embedded cover comes out a perfect square
(360×360) — the `--ppa` crop recipe survives yt-dlp's shlex split + ffmpeg's filtergraph.* (360×360) — the `--ppa` crop recipe survives yt-dlp's shlex split + ffmpeg's filtergraph.*
@@ -126,10 +127,12 @@ extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write
the Custom commands card). The **"run custom command" mode** is the Custom commands card). The **"run custom command" mode** is
`Settings.customCommandEnabled`, applied to every new download unless overridden `Settings.customCommandEnabled`, applied to every new download unless overridden
per-download in the download bar's Custom command panel. per-download in the download bar's Custom command panel.
- [x] **Command preview.** `IpcChannels.commandPreview` `previewCommand()` - [ ] **Command preview (plumbed, not surfaced).** `IpcChannels.commandPreview`
(`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path `previewCommand()` (`src/main/download.ts`) builds the exact argv via the same
a real download would take, then renders it with `formatCommandLine`. Surfaced as `buildCommand()` path a real download would take, then renders it with
a **Preview command** button in the download bar, with a Copy button. `formatCommandLine`. The **Preview command** button is NOT yet in the download bar
only the main/preload/buildArgs chain exists. Wire it into the per-download Options
panel or remove the chain (audit M5/UX1).
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs - [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
next to the existing version check, with a channel picker + result output. next to the existing version check, with a channel picker + result output.
@@ -154,9 +157,12 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
- [x] **Native OS notifications** on completion / failure (Electron `Notification`, - [x] **Native OS notifications** on completion / failure (Electron `Notification`,
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch `src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window. (`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
- [x] **Private / incognito mode** — a download bar toggle (`DownloadBar.tsx`, sticky like an - [ ] **Private / incognito mode (plumbed, not surfaced).** `DownloadItem.incognito` flows
incognito tab) sets `DownloadItem.incognito`, which `store/downloads.ts` checks before through `buildItem` → history-skip → the QueueItem "Private" badge, and
ever calling `useHistory().add(...)`. Queue items show an eye-off badge when private. `store/downloads.ts` checks it before ever calling `useHistory().add(...)`. But no UI
control sets `incognito: true` yet — the download-bar toggle is not wired (audit M6/UX1).
Note `download.ts` would still log a failure / show a completion title for a private item,
so the "private" promise currently covers history only (audit L136).
- [x] **Backup / restore** settings + templates (export / import JSON). New - [x] **Backup / restore** settings + templates (export / import JSON). New
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog; `src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a `replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
@@ -171,9 +177,9 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
Some items are Android-specific in Seal and adapted to Windows here. Some items are Android-specific in Seal and adapted to Windows here.
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a - [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
Theme select (Light / Dark / **Follow system**) and four accent presets — Toffee Theme select (Light / Dark / **Follow system**) and four accent presets — Rose,
(original), Slate, Evergreen, Lavender — each a 16-stop `BrandVariants` ramp sharing Coral, Amber, Teal ("Sunset-to-sea", default Teal) — each a 16-stop `BrandVariants`
toffee's lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow ramp sharing one lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to (`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
the renderer over a new `system-theme:update` channel into the renderer over a new `system-theme:update` channel into
@@ -184,8 +190,8 @@ Some items are Android-specific in Seal and adapted to Windows here.
Chromium's native `forced-colors` handling since nothing in the app sets Chromium's native `forced-colors` handling since nothing in the app sets
`forced-color-adjust: none`. The sidebar's quick toggle still works under "system" — it `forced-color-adjust: none`. The sidebar's quick toggle still works under "system" — it
sets an explicit light/dark away from whatever's currently resolved, breaking out of sets an explicit light/dark away from whatever's currently resolved, breaking out of
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee, auto. Verified in the UI preview (typecheck + `npm run test` clean): default Teal,
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar switching to Rose/Coral/Amber, Light/Dark/Follow-system, and the sidebar
toggle's break-out-of-system behavior all checked visually. toggle's break-out-of-system behavior all checked visually.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32 - [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target: (non-MSIX) app can't register as an actual Share Target:
@@ -285,14 +291,11 @@ YTDLnis's signature differentiator. The keyframe plumbing already exists in Aero
keeps the full file and writes per-chapter files via its default `chapter:` template. keeps the full file and writes per-chapter files via its default `chapter:` template.
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test *Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
still pending.* still pending.*
- [ ] **Metadata editing before download.** Change title / author / artist pre-download - [x] **Metadata editing before download.** Change title / author / artist pre-download
(YTDLnis: "modify metadata such as title and author"). `--parse-metadata` / (YTDLnis: "modify metadata such as title and author"). Uses `--replace-in-metadata FIELD ^.*$ VALUE`
`--replace-in-metadata`; fits the audio path. Good for building a clean music library (not `--parse-metadata FROM:TO` which splits on colons). Only backslashes need escaping in the
where the source title is messy. *Held: the exact set-a-literal recipe is fragile replacement string. Title, Artist, Album inputs in `DownloadOptionsForm.tsx`; setting any override
(`--parse-metadata FROM:TO` splits on a colon that titles often contain; implicitly enables `--embed-metadata`. Unit-tested in `buildArgs.test.ts`.
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
flag.*
## Phase M — Queue & daily-use UX ✅ COMPLETE ## Phase M — Queue & daily-use UX ✅ COMPLETE
@@ -339,8 +342,10 @@ Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as `datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`; scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while for *sources*). The queue is now persisted across restarts (audit M4 — `queue.json` via
AeroFetch is running** — noted in the UI hint and the code.* `src/main/queue.ts`), so a parked/scheduled item survives a quit and its schedule fires on the
next launch once due. (The app must still be running at the scheduled moment for an immediate
fire; a schedule that came due while closed fires at the next launch.)*
## Phase N — Power-user surface ✅ COMPLETE ## Phase N — Power-user surface ✅ COMPLETE
@@ -384,9 +389,10 @@ All in the main process. typecheck + test + `npm run build` clean.
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the - [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage` single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
assets this repo doesn't have tooling to generate here.* assets this repo doesn't have tooling to generate here.*
- [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state. - [x] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
*Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the Green dot when downloading, red dot on error, cleared when idle. Pure-JS PNG generator in
taskbar progress bar's `error` mode already signals failures.* `src/main/badge.ts` (Buffer + zlib, no new deps); badgeCount threaded through the existing
`taskbarProgress` IPC channel from `summarizeQueue`.
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards - [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
live by matching each card's text (DOM `display` toggle keyed off the root's children — no live by matching each card's text (DOM `display` toggle keyed off the root's children — no
per-card refactor), with a "no settings match" hint.* per-card refactor), with a "no settings match" hint.*
@@ -400,17 +406,21 @@ build clean); the parts that inherently need a human or a paid cert are delivere
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group `youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network. (`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.* Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
**Deferred: automatic WebView token minting** (the YTDLnis headline) — the hard part; **Automatic WebView token minting shipped:** `src/main/poToken.ts` opens a YouTube video
this is the argv plumbing it would feed. Cookies remain the easier bot-check fix. page in a hardened BrowserWindow (shared login session), injects an async poll for
`ytInitialPlayerResponse.serviceIntegrityDimensions.poToken`, saves the result encrypted
via `safeStorage`. "Fetch" button in Settings → Network triggers it via `youtube:po-token-mint`
IPC. Falls back gracefully (null) if YouTube's page structure changes.
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install). - [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) — Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact `--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
steps + expected results. **Still needs a human to run it.** steps + expected results. **Still needs a human to run it.**
- [x] **Code signing.** *Build is signing-ready: electron-builder picks the cert up from - [x] **Code signing — non-goal.** *Decision: no certificate will be purchased, so builds ship
`CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change (unset = unsigned, as today). Documented unsigned and the SmartScreen first-run prompt is accepted. The build stays signing-ready —
in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV, local + CI, HSM/EV hook, signature electron-builder picks the cert up from `CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change
verification). **Actual signing needs a purchased certificate.*** (unset = unsigned, as today), documented in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV,
local + CI, HSM/EV hook, signature verification) — if that decision is ever reversed.*
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language - [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
picker that doesn't change anything would be misleading; doing it properly means wiring an picker that doesn't change anything would be misleading; doing it properly means wiring an
i18n library + extracting every string, with translations arriving incrementally. Tracked, i18n library + extracting every string, with translations arriving incrementally. Tracked,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 361 KiB

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

Before

Width:  |  Height:  |  Size: 699 B

After

Width:  |  Height:  |  Size: 1.4 KiB

+14 -6
View File
@@ -1,6 +1,6 @@
appId: com.aerofetch.app appId: com.aerofetch.app
productName: AeroFetch productName: AeroFetch
copyright: yt-dlp frontend copyright: Copyright © 2026 AeroFetch
# Lets a browser/another app hand AeroFetch a link via aerofetch://download?url=<encoded> # Lets a browser/another app hand AeroFetch a link via aerofetch://download?url=<encoded>
# (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent. # (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent.
@@ -15,13 +15,18 @@ directories:
buildResources: build buildResources: build
output: dist output: dist
# Generate a <installer>.exe.sha256 next to every built .exe (H8). The updater
# hard-requires this checksum asset or it refuses to install the update.
afterAllArtifactBuild: ./scripts/generate-checksums.cjs
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{eslint.config.js,.prettierrc,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json,tsconfig.tsbuildinfo}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!*.tsbuildinfo'
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned # yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath. # directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
@@ -46,9 +51,9 @@ win:
- portable - portable
# Never request admin elevation. # Never request admin elevation.
requestedExecutionLevel: asInvoker requestedExecutionLevel: asInvoker
# Placeholder icon (audit M3): white download glyph on the teal brand square, # App icon (audit W14): white download glyph on the teal brand square with a
# mirroring the in-app brand mark. Generated from build/icon.svg as a multi-size # top-lit gradient. Generated from build/icon.svg as a multi-size .ico
# .ico (256/128/64/48/32/16). Replace with a designed asset before v1.0 — re-run # (256/128/64/48/32/16). After editing the SVG, regenerate with:
# magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico # magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico
icon: build/icon.ico icon: build/icon.ico
@@ -63,4 +68,7 @@ nsis:
perMachine: false perMachine: false
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false deleteAppDataOnUninstall: false
# The custom updater (src/main/updater.ts) does a full download and never consumes
# the differential .blockmap, so don't emit it (audit L83 — dead build artifact).
differentialPackage: false
# Flip perMachine to true for an all-users install to Program Files (requires admin). # Flip perMachine to true for an all-users install to Program Files (requires admin).
+8 -2
View File
@@ -9,7 +9,11 @@ export default defineConfig({
alias: { alias: {
'@shared': resolve('src/shared') '@shared': resolve('src/shared')
} }
} },
// Hidden source maps: emit .map files so a field crash stack (the logger writes
// raw stacks, CC8) can be symbolicated offline, without referencing/exposing them
// in the shipped bundle (L170).
build: { sourcemap: 'hidden' }
}, },
preload: { preload: {
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
@@ -21,6 +25,7 @@ export default defineConfig({
// Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling // Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling
// the sandbox (webPreferences.sandbox) is part of the security hardening. // the sandbox (webPreferences.sandbox) is part of the security hardening.
build: { build: {
sourcemap: 'hidden',
rollupOptions: { rollupOptions: {
output: { output: {
format: 'cjs', format: 'cjs',
@@ -36,6 +41,7 @@ export default defineConfig({
'@shared': resolve('src/shared') '@shared': resolve('src/shared')
} }
}, },
plugins: [react()] plugins: [react()],
build: { sourcemap: 'hidden' }
} }
}) })
+43
View File
@@ -0,0 +1,43 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import globals from 'globals'
import prettier from 'eslint-config-prettier'
// Flat config (ESLint 9 + typescript-eslint 8). Codifies the existing house style
// (CC2): typescript-eslint's recommended logic/type rules, with formatting left
// entirely to Prettier (eslint-config-prettier turns off any stylistic rules).
// Run with `npm run lint`; `npm run format` applies Prettier.
export default tseslint.config(
{ ignores: ['out/**', 'dist/**', 'node_modules/**', '**/*.cjs'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
globals: { ...globals.node, ...globals.browser }
},
rules: {
// Empty catch blocks are a deliberate best-effort pattern across the app.
'no-empty': ['error', { allowEmptyCatch: true }],
// Allow a documented @ts-ignore. The preload's window fallback needs one
// whose necessity is incremental-build-state-dependent, so @ts-expect-error
// (which errors when momentarily unused) is the wrong tool there.
'@typescript-eslint/ban-ts-comment': [
'error',
{ 'ts-ignore': 'allow-with-description', minimumDescriptionLength: 3 }
],
// Allow intentionally-unused names prefixed with `_` (e.g. IPC event args).
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
}
},
{
// TypeScript already resolves identifiers/types, so core no-undef only
// false-positives on globals and type-only references here.
files: ['**/*.{ts,tsx}'],
rules: { 'no-undef': 'off' }
},
// Keep Prettier last so it wins over any formatting-related rule.
prettier
)
+1218 -1
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -4,7 +4,12 @@
"description": "A yt-dlp frontend for Windows", "description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "AeroFetch", "author": "AeroFetch",
"homepage": "https://github.com/yt-dlp/yt-dlp", "license": "UNLICENSED",
"homepage": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch",
"repository": {
"type": "git",
"url": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git"
},
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -14,12 +19,18 @@
"ui:build": "vite build", "ui:build": "vite build",
"ui:preview": "vite preview", "ui:preview": "vite preview",
"build": "electron-vite build", "build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win", "clean:dist": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"build:win": "npm run clean:dist && electron-vite build && electron-builder --win",
"start": "electron-vite preview", "start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest run" "lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
"test": "vitest run",
"test:integration": "set AEROFETCH_REAL_DOWNLOAD=1&& vitest run test/real-download.integration.test.ts"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
@@ -33,6 +44,7 @@
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0",
"@eslint/js": "^9.39.4",
"@types/node": "^26.0.0", "@types/node": "^26.0.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -40,7 +52,12 @@
"electron": "^42.4.1", "electron": "^42.4.1",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"globals": "^15.15.0",
"prettier": "^3.9.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"vite": "^7.3.5", "vite": "^7.3.5",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
+36
View File
@@ -0,0 +1,36 @@
// electron-builder `afterAllArtifactBuild` hook (H8).
//
// The in-app updater (src/main/updater.ts) sets REQUIRE_CHECKSUM = true and
// refuses any release whose installer lacks a sibling `<installer>.exe.sha256`
// asset — so a release published without one makes EVERY client's in-app update
// fail. `build:win` never generated these, so they had to be made by hand (and
// the 0.5.0 build shipped without any). This hook writes one next to every built
// `.exe`, in `sha256sum` format (`<lowercase-hex> <filename>`), which the
// updater's extractSha256 parses (it just needs a standalone 64-char hex token).
//
// Returning the paths tells electron-builder to also upload them as release
// assets when publishing.
const { createHash } = require('crypto')
const { readFileSync, writeFileSync } = require('fs')
const { basename } = require('path')
/** The `sha256sum`-format line for a file: "<lowercase-hex> <basename>". */
function checksumLine(filePath) {
const hex = createHash('sha256').update(readFileSync(filePath)).digest('hex')
return `${hex} ${basename(filePath)}`
}
exports.default = function generateChecksums(context) {
const written = []
for (const file of context.artifactPaths) {
if (!/\.exe$/i.test(file)) continue
const out = `${file}.sha256`
writeFileSync(out, checksumLine(file) + '\n')
written.push(out)
}
return written
}
// Exported for unit testing the hashing/format without running electron-builder.
exports.checksumLine = checksumLine
+30 -20
View File
@@ -1,14 +1,9 @@
import { dialog, type BrowserWindow } from 'electron' import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs' import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings' import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { listTemplates, replaceTemplates } from './templates' import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation' import { isTemplateLike } from './validation'
import type { import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
interface BackupFile { interface BackupFile {
version: 1 version: 1
@@ -17,12 +12,18 @@ interface BackupFile {
} }
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> { export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
const res = await dialog.showSaveDialog(win!, { const opts = {
defaultPath: 'aerofetch-backup.json', defaultPath: 'aerofetch-backup.json',
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) }
const res = await (win ? dialog.showSaveDialog(win, opts) : dialog.showSaveDialog(opts))
if (res.canceled || !res.filePath) return { ok: false } if (res.canceled || !res.filePath) return { ok: false }
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() } // Strip credentials (proxy creds, API tokens) so a backup file shared or synced
// to the cloud doesn't leak secrets in plaintext (M22). The user re-enters them
// after import. Shares SECRET_KEYS with settings.ts so the lists can't drift.
const settings = { ...getSettings() }
for (const key of SECRET_KEYS) (settings as Record<string, unknown>)[key] = ''
const payload: BackupFile = { version: 1, settings, templates: listTemplates() }
try { try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2)) writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath } return { ok: true, path: res.filePath }
@@ -32,10 +33,11 @@ export async function exportBackup(win: BrowserWindow | undefined): Promise<Back
} }
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> { export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
const res = await dialog.showOpenDialog(win!, { const openOpts = {
properties: ['openFile'], properties: ['openFile' as const],
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) }
const res = await (win ? dialog.showOpenDialog(win, openOpts) : dialog.showOpenDialog(openOpts))
if (res.canceled || !res.filePaths[0]) return { ok: false } if (res.canceled || !res.filePaths[0]) return { ok: false }
let parsed: unknown let parsed: unknown
@@ -85,10 +87,9 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
return `${name}: ${args}` return `${name}: ${args}`
}) })
.join('\n') .join('\n')
const more = const more = commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : '' const msgOpts = {
const choice = await dialog.showMessageBox(win!, { type: 'warning' as const,
type: 'warning',
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'], buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
defaultId: 1, defaultId: 1,
cancelId: 2, cancelId: 2,
@@ -100,17 +101,26 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' + 'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
preview + preview +
more more
}) }
const choice = await (win
? dialog.showMessageBox(win, msgOpts)
: dialog.showMessageBox(msgOpts))
if (choice.response === 2) return { ok: false } // Cancel — change nothing if (choice.response === 2) return { ok: false } // Cancel — change nothing
applyCustomCommands = choice.response === 0 applyCustomCommands = choice.response === 0
} }
if (incomingSettings) { if (incomingSettings) {
// Never restore credential fields from a backup. Exports strip them (M22), so an
// imported '' would otherwise wipe a proxy/token already configured on this
// machine; honoring a hand-edited one would reintroduce the leak vector. Either
// way, import leaves the user's existing secrets untouched — they re-enter as needed.
const restored: Partial<Settings> = { ...incomingSettings }
for (const key of SECRET_KEYS) delete restored[key]
// When the user declined to enable custom commands (or there were none to // When the user declined to enable custom commands (or there were none to
// enable), force the toggle off so an imported defaultTemplateId can't auto-run. // enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands const safeSettings = applyCustomCommands
? incomingSettings ? restored
: { ...incomingSettings, customCommandEnabled: false } : { ...restored, customCommandEnabled: false }
setSettings(safeSettings) setSettings(safeSettings)
} }
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) { if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
+87
View File
@@ -0,0 +1,87 @@
import { deflateSync } from 'zlib'
import { nativeImage, type NativeImage } from 'electron'
// CRC32 table polynomial used by PNG.
function crc32(buf: Buffer): number {
let crc = 0xffffffff
for (const b of buf) {
crc ^= b
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
}
return (crc ^ 0xffffffff) >>> 0
}
function pngChunk(type: string, data: Buffer): Buffer {
const t = Buffer.from(type, 'ascii')
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length)
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
return Buffer.concat([len, t, data, crc])
}
/**
* Build a 16×16 RGBA PNG containing a solid-colour filled circle. Used for the
* Windows taskbar overlay badge. No external deps — pure Node.js (Buffer + zlib).
*/
function makeDotPng(r: number, g: number, b: number): NativeImage {
const W = 16,
H = 16
const px = Buffer.alloc(W * H * 4, 0) // transparent bg
const cx = W / 2,
cy = H / 2,
rad = W / 2 - 1.5
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x + 0.5 - cx,
dy = y + 0.5 - cy
if (dx * dx + dy * dy <= rad * rad) {
const i = (y * W + x) * 4
px[i] = r
px[i + 1] = g
px[i + 2] = b
px[i + 3] = 255
}
}
}
// PNG: IHDR (8-bit RGBA, no interlace), filter-0 rows, deflated IDAT, IEND.
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(W, 0)
ihdr.writeUInt32BE(H, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // colour type: RGBA
const rows = Buffer.alloc(H * (1 + W * 4))
for (let y = 0; y < H; y++) {
rows[y * (1 + W * 4)] = 0 // filter type: None
px.copy(rows, y * (1 + W * 4) + 1, y * W * 4, (y + 1) * W * 4)
}
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const png = Buffer.concat([
sig,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(rows)),
pngChunk('IEND', Buffer.alloc(0))
])
return nativeImage.createFromBuffer(png)
}
// Lazy-initialised singletons — created on first use so this module is safe to
// import before the app is ready (nativeImage.createFromBuffer is fine post-ready).
let activeBadge: NativeImage | null = null
let errorBadge: NativeImage | null = null
/** Green dot badge for the taskbar overlay — shown when downloads are active. */
export function getActiveBadge(): NativeImage {
if (!activeBadge) activeBadge = makeDotPng(58, 185, 108) // #3ab96c
return activeBadge
}
/** Red dot badge for the taskbar overlay — shown when a download has errored. */
export function getErrorBadge(): NativeImage {
if (!errorBadge) errorBadge = makeDotPng(217, 48, 37) // #d93025
return errorBadge
}
+23 -2
View File
@@ -1,4 +1,4 @@
import { app } from 'electron' import { app, nativeImage, type NativeImage } from 'electron'
import { join } from 'path' import { join } from 'path'
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
@@ -24,6 +24,23 @@ export function getAppIconPath(): string {
: join(process.resourcesPath, 'icon.ico') : join(process.resourcesPath, 'icon.ico')
} }
let appIconImage: NativeImage | null = null
/**
* The app icon as a cached NativeImage, for OS notifications (W13/L127). Loaded
* once from getAppIconPath(); a missing icon yields an empty image, which
* Notification treats as "no icon" (the OS default) rather than erroring. This
* gives completion/background toasts the real brand glyph — notably on the
* portable build, which has no installed AUMID shortcut icon for Windows to use.
*/
export function getAppIconImage(): NativeImage {
// Cache only a valid image so a transient read miss isn't latched forever; a
// genuinely-missing icon just re-reads (cheap — notifications are infrequent).
if (!appIconImage || appIconImage.isEmpty()) {
appIconImage = nativeImage.createFromPath(getAppIconPath())
}
return appIconImage
}
/** /**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate * AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what * from the read-only bundled seed in resources/bin. The managed copy is what
@@ -35,7 +52,7 @@ export function getAppIconPath(): string {
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and * ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`. * yt-dlp finds them via `--ffmpeg-location <binDir>`.
*/ */
export function getManagedBinDir(): string { function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin') return join(app.getPath('userData'), 'bin')
} }
@@ -63,6 +80,10 @@ export function getFfprobePath(): string {
return join(getBinDir(), 'ffprobe.exe') return join(getBinDir(), 'ffprobe.exe')
} }
/** User-facing error shown by download/probe/index/update when yt-dlp.exe is missing. */
export const YTDLP_MISSING_MSG =
'yt-dlp.exe is missing. Open Settings → Software update to re-download it.'
/** Optional bundled external downloader; absent unless dropped into resources/bin. */ /** Optional bundled external downloader; absent unless dropped into resources/bin. */
export function getAria2cPath(): string { export function getAria2cPath(): string {
return join(getBinDir(), 'aria2c.exe') return join(getBinDir(), 'aria2c.exe')
+79 -23
View File
@@ -42,9 +42,7 @@ function videoFormat(quality: string): string {
*/ */
function videoSelector(opts: StartDownloadOptions): string { function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) { if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
? opts.formatId
: `${opts.formatId}+bestaudio/${opts.formatId}`
} }
return videoFormat(opts.quality) return videoFormat(opts.quality)
} }
@@ -57,18 +55,27 @@ function audioQuality(quality: string): string {
return '192K' return '192K'
case '128 kbps': case '128 kbps':
return '128K' return '128K'
case 'Best':
return '0'
default: default:
return '0' // Best // Unrecognised label — fall back to best (0) so the download still works.
return '0'
} }
} }
// Stdout line markers, shared by the emit side (the --print templates here) and
// the parse side (download.ts). Defined once so changing a marker can't silently
// break the parser that splits on it (CL1).
export const PROGRESS_MARKER = 'prog|'
export const FILEPATH_MARKER = 'path|'
// The progress line yt-dlp emits (one per --newline tick). Note the leading // The progress line yt-dlp emits (one per --newline tick). Note the leading
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp // `download:` is the progress-template TYPE selector and is consumed by yt-dlp
// (it does NOT appear in the output). The literal `prog|` that follows is our // (it does NOT appear in the output). The PROGRESS_MARKER that follows is our
// own marker, so we can tell progress lines apart from the after-move filepath // own marker, so we can tell progress lines apart from the after-move filepath
// print on the same stdout stream. // print on the same stdout stream.
export const PROGRESS_TEMPLATE = const PROGRESS_TEMPLATE =
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' + `download:${PROGRESS_MARKER}%(progress.status)s|%(progress.downloaded_bytes)s|` +
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' + '%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s' '%(progress.speed)s|%(progress.eta)s'
@@ -77,8 +84,7 @@ export const PROGRESS_TEMPLATE =
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are // double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
// protected from ffmpeg's filtergraph separator. Side = min(width, height). // protected from ffmpeg's filtergraph separator. Side = min(width, height).
export const CROP_SQUARE_PPA = export const CROP_SQUARE_PPA =
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + "crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
/** /**
* Phase B "Access & networking" settings — not per-download options, always * Phase B "Access & networking" settings — not per-download options, always
@@ -127,7 +133,9 @@ export function parseExtraArgs(raw: string): string[] {
const re = /"([^"]*)"|'([^']*)'|(\S+)/g const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) { while ((m = re.exec(raw)) !== null) {
args.push(m[1] ?? m[2] ?? m[3]) // Exactly one of the three alternation groups matches; the '' fallback only
// satisfies the type checker (noUncheckedIndexedAccess) and never fires.
args.push(m[1] ?? m[2] ?? m[3] ?? '')
} }
return args return args
} }
@@ -197,7 +205,10 @@ export function matchesUrl(pattern: string, url: string): boolean {
*/ */
export function parseTrimSections(raw: string | undefined): string[] { export function parseTrimSections(raw: string | undefined): string[] {
if (!raw) return [] if (!raw) return []
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?` // A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old
// `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside
// yt-dlp's --download-sections rather than being rejected up front (L146).
const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?`
const RANGE = new RegExp(`^${TIME}-${TIME}$`) const RANGE = new RegExp(`^${TIME}-${TIME}$`)
const out: string[] = [] const out: string[] = []
for (const piece of raw.split(/[,\n]/)) { for (const piece of raw.split(/[,\n]/)) {
@@ -244,7 +255,11 @@ export function sanitizeDirSegment(name: string): string {
// byte ever appears in this source file. // byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('') let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ') s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '') s = s
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '')
.replace(/^[. ]+/, '')
// Reserved device names are reserved even WITH an extension ('CON.txt' is still // 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). // 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}` if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
@@ -328,7 +343,23 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
// Split into one file per chapter. yt-dlp also keeps the full file; the // Split into one file per chapter. yt-dlp also keeps the full file; the
// per-chapter files use yt-dlp's default `chapter:` output template. // per-chapter files use yt-dlp's default `chapter:` output template.
if (o.splitChapters) args.push('--split-chapters') if (o.splitChapters) args.push('--split-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Metadata embedding + optional per-field overrides.
// --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata
// FROM:TO because the replacement arg is a separate element (no colon-splitting
// on values like "Foo: Bar"). ^.*$ matches any string including empty. The only
// escaping Python's re.sub needs in the replacement string is backslash.
const metaOverrides: [string, string | undefined][] = [
['title', o.metadataTitle],
['artist', o.metadataArtist],
['album', o.metadataAlbum]
]
const hasOverrides = metaOverrides.some(([, v]) => v?.trim())
if (o.embedMetadata || hasOverrides) args.push('--embed-metadata')
const escRepl = (s: string): string => s.replace(/\\/g, '\\\\')
for (const [field, val] of metaOverrides) {
if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim()))
}
// Media-server sidecar files (Phase K) — written next to the output file so // Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description. // Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
@@ -339,18 +370,39 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
return args return args
} }
export function buildArgs( /**
opts: StartDownloadOptions, * Everything buildArgs needs to construct a yt-dlp argv. A single options object
outputTemplate: string, * (rather than six positional params) so callers can't transpose `opts`/`options`
o: DownloadOptions, * or the two path-like strings, and new inputs can be added without churning every
binDir: string, * call site (CL2).
access: AccessOptions, */
extraArgs: string[] = [] export interface BuildArgsInput {
): string[] { /** The per-download request (url, kind, quality, chosen format, trim, …). */
opts: StartDownloadOptions
/** The resolved `-o` output template (flat filename or collection folder tree). */
outputTemplate: string
/** The post-processing options group (per-download override or the persisted default). */
options: DownloadOptions
/** ffmpeg/yt-dlp bin dir, passed in so this module stays free of path resolution. */
binDir: string
/** Global access/networking settings (proxy, cookies, rate limit, …). */
access: AccessOptions
/** Custom-command extra args, already consent-gated by the caller. */
extraArgs?: string[]
}
export function buildArgs(input: BuildArgsInput): string[] {
const { opts, outputTemplate, options: o, binDir, access, extraArgs = [] } = input
const args = [ const args = [
'--newline', '--newline',
'--no-color', '--no-color',
'--no-playlist', '--no-playlist',
// Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a
// stalled socket instead of hanging forever and pinning a concurrency slot
// with no recovery. The app-side idle watchdog in download.ts is the backstop
// for a fully-wedged process. (B1)
'--socket-timeout',
'30',
// --print (below) implies --quiet, which would suppress progress; --progress // --print (below) implies --quiet, which would suppress progress; --progress
// forces the progress template to emit anyway. // forces the progress template to emit anyway.
'--progress', '--progress',
@@ -362,7 +414,7 @@ export function buildArgs(
PROGRESS_TEMPLATE, PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later. // Print the final path after post-processing/move so we can open it later.
'--print', '--print',
'after_move:path|%(filepath)s', `after_move:${FILEPATH_MARKER}%(filepath)s`,
'--no-simulate' '--no-simulate'
] ]
@@ -370,7 +422,11 @@ export function buildArgs(
args.push(...postProcessArgs(opts, o)) args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') { if (opts.kind === 'audio') {
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality)) // --audio-quality is a bitrate selector meaningful only for lossy re-encodes.
// For lossless formats (flac/wav) it is silently ignored by yt-dlp (M21).
const lossless = o.audioFormat === 'flac' || o.audioFormat === 'wav'
args.push('-x', '--audio-format', o.audioFormat)
if (!lossless) args.push('--audio-quality', audioQuality(opts.quality))
if (o.embedThumbnail) { if (o.embedThumbnail) {
args.push('--embed-thumbnail') args.push('--embed-thumbnail')
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA) if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
+67
View File
@@ -0,0 +1,67 @@
/**
* Central home for the main-process timeouts, buffer sizes, and store caps that
* were previously scattered as bare literals across the spawn/probe/index/store
* modules (L10). Collecting them here makes the operational envelope reviewable
* in one place and stops the same "how long / how big" decision drifting between
* modules. Values that are already single-sourced in the shared contract
* (e.g. HISTORY_MAX_ENTRIES) stay there; this file is for the main-only ones.
*/
// --- Child-process timeouts (ms) --------------------------------------------
/** `<binary> -version` probes (yt-dlp, ffmpeg, ffprobe) — a quick liveness call. */
export const VERSION_TIMEOUT_MS = 15_000
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
export const META_PROBE_TIMEOUT_MS = 30_000
/** Full format/metadata probe (`-J`) for the download bar. */
export const PROBE_TIMEOUT_MS = 60_000
/** Channel/playlist indexing walk — a big channel legitimately takes minutes. */
export const INDEX_TIMEOUT_MS = 180_000
/** RSS feed fetch for a watched source's new-item check. */
export const FEED_FETCH_TIMEOUT_MS = 15_000
/**
* Idle watchdog for a running download: if a spawned yt-dlp emits no stdout or
* stderr for this long, treat it as wedged and kill it so the concurrency slot
* frees (B1). Generous on purpose so a long, output-less post-processing step
* (e.g. a large ffmpeg merge) isn't mistaken for a stall.
*/
export const STALL_TIMEOUT_MS = 5 * 60_000
// --- execFile maxBuffer sizes (bytes) ---------------------------------------
// yt-dlp JSON for a big channel/format list is large; cap generously so a valid
// response is never truncated (which would look like a parse failure).
/** Metadata --print output (three short fields). */
export const META_MAX_BUFFER = 4 * 1024 * 1024
/** Single-video `-J` probe JSON. */
export const PROBE_MAX_BUFFER = 64 * 1024 * 1024
/** Whole-channel index JSON. */
export const INDEX_MAX_BUFFER = 256 * 1024 * 1024
// --- Misc ------------------------------------------------------------------
/** How much of a failed download's stderr to retain for the error message. */
export const STDERR_TAIL_BYTES = 4000
/**
* Size cap for the diagnostic log file (logger.ts, CC8). Once the live
* `aerofetch.log` passes this, it's rotated to `aerofetch.log.1` (one generation
* kept) so app logging can't fill the disk.
*/
export const LOG_MAX_BYTES = 1024 * 1024
// --- JSON-store row caps ----------------------------------------------------
// Each hand-rolled JSON store trims to its cap on write so a store file can't
// grow without bound.
/** Diagnostics error log (errorlog.json). */
export const ERRORLOG_MAX_ENTRIES = 200
/** Saved custom-command templates (templates.json). */
export const TEMPLATES_MAX = 100
/** Indexed media items across all sources (media-items.json). */
export const MEDIA_ITEMS_MAX = 20_000
/** Persisted download-queue rows (queue.json, M4) — generous; a whole channel can be queued. */
export const QUEUE_MAX = 10_000
+45
View File
@@ -0,0 +1,45 @@
import { Menu, type WebContents, type MenuItemConstructorOptions } from 'electron'
/**
* Give editable fields (and any selected text) the standard Windows Cut / Copy /
* Paste / Select All right-click menu that Electron does not provide by default
* (W4) — plus spellcheck suggestions for text areas. The menu items use built-in
* roles, so the editing works directly in the sandboxed, context-isolated
* renderer without any extra IPC. Wired onto the main window only; the untrusted
* cookie sign-in window deliberately keeps its minimal chrome.
*/
export function attachEditContextMenu(wc: WebContents): void {
wc.on('context-menu', (_e, params) => {
const { isEditable, editFlags, selectionText, dictionarySuggestions } = params
// Only worth a menu over an editable field or a real text selection.
if (!isEditable && !selectionText.trim()) return
const template: MenuItemConstructorOptions[] = []
// Spellcheck suggestions first, when the caret sits on a misspelling.
if (isEditable && dictionarySuggestions.length > 0) {
for (const suggestion of dictionarySuggestions) {
template.push({ label: suggestion, click: () => wc.replaceMisspelling(suggestion) })
}
template.push({ type: 'separator' })
}
if (isEditable) {
template.push(
{ role: 'cut', enabled: editFlags.canCut },
{ role: 'copy', enabled: editFlags.canCopy },
{ role: 'paste', enabled: editFlags.canPaste },
{ type: 'separator' },
{ role: 'selectAll', enabled: editFlags.canSelectAll }
)
} else {
// Read-only text with a selection: offer Copy (and Select All).
template.push(
{ role: 'copy', enabled: editFlags.canCopy },
{ role: 'selectAll', enabled: editFlags.canSelectAll }
)
}
Menu.buildFromTemplate(template).popup()
})
}
+127 -18
View File
@@ -1,15 +1,17 @@
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron' import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs' import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { logger } from './logger'
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc' import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
/** /**
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate * Persisted session AeroFetch's built-in sign-in window uses. Kept separate
* from the main window's (default) session so a logged-in site can't see or * from the main window's (default) session so a logged-in site can't see or
* touch anything the app itself loads. * touch anything the app itself loads. Exported so the PO-token window can
* share the same session (and thus the user's YouTube sign-in state).
*/ */
const PARTITION = 'persist:aerofetch-login' export const LOGIN_PARTITION = 'persist:aerofetch-login'
/** /**
* The sign-in window renders untrusted remote content, so every navigation and * The sign-in window renders untrusted remote content, so every navigation and
@@ -43,7 +45,7 @@ function hardenLoginWebContents(wc: WebContents): void {
overrideBrowserWindowOptions: { overrideBrowserWindowOptions: {
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
partition: PARTITION, partition: LOGIN_PARTITION,
sandbox: true, sandbox: true,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
@@ -60,20 +62,99 @@ function hardenLoginWebContents(wc: WebContents): void {
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents)) wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
} }
export function getCookiesFilePath(): string { // The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
// a plaintext file, so the stored form is encrypted via Electron safeStorage
// (DPAPI on Windows — the same protection settings secrets get) and only ever
// decrypted to a short-lived temp file for the duration of a download. This
// matters for the portable build, where userData sits next to the exe (USB /
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
function cookieStorePath(): string {
return join(app.getPath('userData'), 'cookies.dat')
}
function legacyCookiePath(): string {
return join(app.getPath('userData'), 'cookies.txt') return join(app.getPath('userData'), 'cookies.txt')
} }
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
const ENC_PREFIX = 'enc:v1:'
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
function storeCookies(netscape: string): void {
let out = netscape
try {
if (safeStorage.isEncryptionAvailable()) {
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
}
} catch {
/* fall through — store plaintext, as it was before encryption existed */
}
writeFileSync(cookieStorePath(), out)
}
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
function loadCookies(): string | null {
const p = cookieStorePath()
if (!existsSync(p)) return null
const stored = readFileSync(p, 'utf8')
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
try {
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
} catch {
// A different Windows user/machine (portable jar copied elsewhere) or a
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
return null
}
}
/**
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
* encrypted store, deleting the plaintext so a logged-in session no longer sits
* in the open after an update (H7). Best-effort; safe to call on every launch.
*/
export function migrateLegacyCookies(): void {
const legacy = legacyCookiePath()
try {
if (!existsSync(legacy)) return
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
unlinkSync(legacy)
} catch {
/* best-effort */
}
}
/** Whether a stored cookie jar exists (regardless of decryptability here). */
export function hasStoredCookies(): boolean {
return existsSync(cookieStorePath())
}
/**
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
* false when there's nothing usable to write. The caller deletes `dest` once the
* download settles, keeping the plaintext window as short as possible.
*/
export function materializeCookies(dest: string): boolean {
const text = loadCookies()
if (text == null) return false
try {
writeFileSync(dest, text)
return true
} catch {
return false
}
}
export function getCookiesStatus(): CookiesStatus { export function getCookiesStatus(): CookiesStatus {
const p = getCookiesFilePath() const p = cookieStorePath()
if (!existsSync(p)) return { exists: false } if (!existsSync(p)) return { exists: false }
return { exists: true, savedAt: statSync(p).mtimeMs } return { exists: true, savedAt: statSync(p).mtimeMs }
} }
export async function clearCookies(): Promise<void> { export async function clearCookies(): Promise<void> {
const p = getCookiesFilePath() const p = cookieStorePath()
if (existsSync(p)) unlinkSync(p) if (existsSync(p)) unlinkSync(p)
await session.fromPartition(PARTITION).clearStorageData() await session.fromPartition(LOGIN_PARTITION).clearStorageData()
} }
/** /**
@@ -89,9 +170,15 @@ function toNetscapeCookieFile(cookies: Cookie[]): string {
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE' const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate) const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
lines.push( lines.push(
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join( [
'\t' domain,
) includeSubdomains,
c.path || '/',
c.secure ? 'TRUE' : 'FALSE',
expiry,
c.name,
c.value
].join('\t')
) )
} }
return lines.join('\n') + '\n' return lines.join('\n') + '\n'
@@ -102,10 +189,13 @@ let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
function exportAndResolve(): void { function exportAndResolve(): void {
session session
.fromPartition(PARTITION) .fromPartition(LOGIN_PARTITION)
.cookies.get({}) .cookies.get({})
.then((cookies) => { .then((cookies) => {
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies)) // Don't persist an empty jar: closing the window without signing in would
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
// turns cookieCount === 0 into a "did you sign in?" hint.
if (cookies.length > 0) storeCookies(toNetscapeCookieFile(cookies))
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length } const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
pendingResolvers.forEach((resolve) => resolve(result)) pendingResolvers.forEach((resolve) => resolve(result))
}) })
@@ -124,7 +214,10 @@ function exportAndResolve(): void {
* window — cookies are exported to a Netscape-format file at that point, * window — cookies are exported to a Netscape-format file at that point,
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature. * ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
*/ */
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> { export function openCookieLoginWindow(
url: string,
parent?: BrowserWindow
): Promise<CookiesLoginResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
let validUrl: string let validUrl: string
try { try {
@@ -136,7 +229,7 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
if (loginWindow && !loginWindow.isDestroyed()) { if (loginWindow && !loginWindow.isDestroyed()) {
pendingResolvers.push(resolve) pendingResolvers.push(resolve)
loginWindow.loadURL(validUrl).catch(() => {}) loginWindow.loadURL(validUrl).catch((e) => logger.error('cookie login loadURL failed', e))
loginWindow.focus() loginWindow.focus()
return return
} }
@@ -149,8 +242,11 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
height: 720, height: 720,
title: 'Sign in — AeroFetch', title: 'Sign in — AeroFetch',
autoHideMenuBar: true, autoHideMenuBar: true,
// Group under the app window instead of taking its own taskbar button
// (W6); a child window stays above its parent without a modal block.
parent: parent && !parent.isDestroyed() ? parent : undefined,
webPreferences: { webPreferences: {
partition: PARTITION, partition: LOGIN_PARTITION,
sandbox: true, sandbox: true,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
@@ -170,13 +266,26 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
hardenLoginWebContents(win.webContents) hardenLoginWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false)) win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
// 'close' fires on a normal user close; a programmatic destroy() fires only
// 'closed'. Latch whichever runs first so the partition is exported exactly
// once and the promise can never hang (B7) — without this, a destroy()
// without a preceding 'close' would leave pendingResolvers pending forever.
let exportStarted = false
const exportOnce = (): void => {
if (exportStarted) return
exportStarted = true
exportAndResolve()
}
win.on('closed', () => { win.on('closed', () => {
loginWindow = null loginWindow = null
// Fallback for destroy()/closed-without-close: the partition outlives the
// window, so a late export still captures whatever cookies were collected.
exportOnce()
}) })
// Closing the window IS "I'm done" — export whatever the partition // Closing the window IS "I'm done" — export whatever the partition
// collected. The partition itself outlives the window, so this is safe // collected. The partition itself outlives the window, so this is safe
// even though cookies.get() resolves after 'close' has already fired. // even though cookies.get() resolves after 'close' has already fired.
win.on('close', exportAndResolve) win.on('close', exportOnce)
win.loadURL(validUrl).catch(() => { win.loadURL(validUrl).catch(() => {
/* navigation errors surface as Chromium's own error page */ /* navigation errors surface as Chromium's own error page */
+3 -2
View File
@@ -2,6 +2,7 @@ import { app, shell, type BrowserWindow } from 'electron'
import { existsSync, openSync, readSync, closeSync } from 'fs' import { existsSync, openSync, readSync, closeSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { parseUrlShortcutContent } from '@shared/ipc'
/** Only ever forward http(s) targets into the app — same restriction the /** 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.
@@ -29,8 +30,8 @@ function readUrlShortcut(path: string): string | null {
fd = openSync(path, 'r') fd = openSync(path, 'r')
const buf = Buffer.alloc(MAX_URL_FILE_BYTES) const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
const bytes = readSync(fd, buf, 0, buf.length, 0) const bytes = readSync(fd, buf, 0, buf.length, 0)
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes)) const url = parseUrlShortcutContent(buf.toString('utf8', 0, bytes))
return match ? asHttpUrl(match[1].trim()) : null return url ? asHttpUrl(url) : null
} catch { } catch {
return null return null
} finally { } finally {
+247 -130
View File
@@ -1,5 +1,6 @@
import { spawn, execFile, type ChildProcess } from 'child_process' import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs' import { existsSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron' import { BrowserWindow, Notification, type WebContents } from 'electron'
import { import {
@@ -8,11 +9,16 @@ import {
getAria2cPath, getAria2cPath,
getFfmpegPath, getFfmpegPath,
getFfprobePath, getFfprobePath,
getSystem32Path getSystem32Path,
getAppIconImage,
YTDLP_MISSING_MSG
} from './binaries' } from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings' import { getSettings } from './settings'
import { execFileAsync } from './lib/exec'
import { createLineBuffer } from './lib/lineBuffer'
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
import { ensureManagedYtdlp } from './ytdlp' import { ensureManagedYtdlp } from './ytdlp'
import { getCookiesFilePath } from './cookies' import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates' import { listTemplates } from './templates'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation' import { isSafeOutputDir } from './validation'
@@ -20,10 +26,18 @@ import {
buildArgs, buildArgs,
selectExtraArgs, selectExtraArgs,
formatCommandLine, formatCommandLine,
collectionOutputTemplate collectionOutputTemplate,
PROGRESS_MARKER,
FILEPATH_MARKER
} from './buildArgs' } from './buildArgs'
import { cleanError } from './log' import { cleanError } from './log'
import { addErrorLog } from './errorlog' import { addErrorLog } from './errorlog'
import {
STALL_TIMEOUT_MS,
META_PROBE_TIMEOUT_MS,
META_MAX_BUFFER,
STDERR_TAIL_BYTES
} from './constants'
import { import {
IpcChannels, IpcChannels,
type StartDownloadOptions, type StartDownloadOptions,
@@ -31,7 +45,6 @@ import {
type CommandPreviewResult, type CommandPreviewResult,
type DownloadEvent, type DownloadEvent,
type DownloadMeta, type DownloadMeta,
type DownloadProgress,
type Settings type Settings
} from '@shared/ipc' } from '@shared/ipc'
@@ -43,6 +56,21 @@ interface ActiveDownload {
const active = new Map<string, ActiveDownload>() const active = new Map<string, ActiveDownload>()
// Per-spawn sequence, so a retry/resume that reuses the item id still gets a
// unique transient cookie file (below) — otherwise a superseded download's
// teardown could unlink the new spawn's jar mid-read.
let spawnSeq = 0
// Remove an item from the active map, but only if THIS rec still owns the slot.
// cancel/pause release the slot synchronously (so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard while the killed tree is
// still tearing down); the doomed child's later 'close'/'error' then runs this as
// a no-op. The identity check matters because a retry/resume reuses the item id,
// so a stale teardown must not evict the newer same-id spawn. (L140/L148)
function releaseActive(id: string, rec: ActiveDownload): void {
if (active.get(id) === rec) active.delete(id)
}
/** /**
* Whether any yt-dlp download is currently running. Used by the window's close * Whether any yt-dlp download is currently running. Used by the window's close
* handler to keep the app alive in the tray (instead of quitting and killing the * handler to keep the app alive in the tray (instead of quitting and killing the
@@ -53,63 +81,22 @@ export function hasActiveDownloads(): boolean {
} }
// --- Formatting helpers (raw yt-dlp numbers → human strings) ---------------- // --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
// parseProgress lives in lib/formatters.ts (no electron import chain) so it can
function num(s?: string): number | undefined { // be unit-tested in isolation (L37). Byte/ETA formatters live in @shared/format.
if (!s || s === 'NA') return undefined export { parseProgress } from './lib/formatters'
const n = Number(s) import { parseProgress } from './lib/formatters'
return Number.isFinite(n) ? n : undefined
}
export function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = bytes / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
function fmtSpeed(bytesPerSec?: number): string | undefined {
if (bytesPerSec == null) return undefined
return `${fmtBytes(bytesPerSec)}/s`
}
function fmtEta(seconds?: number): string | undefined {
if (seconds == null) return undefined
const s = Math.max(0, Math.round(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
function parseProgress(rest: string): DownloadProgress | null {
const parts = rest.split('|')
if (parts.length < 6) return null
const [status, dl, total, totalEst, speed, eta] = parts
const downloaded = num(dl)
const totalBytes = num(total) ?? num(totalEst)
let progress = 0
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
return {
status: status || 'downloading',
progress,
speed: fmtSpeed(num(speed)),
eta: fmtEta(num(eta)),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined
}
}
function send(wc: WebContents, ev: DownloadEvent): void { function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev) if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
} }
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */ /** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
function notify(wc: WebContents, title: string, body: string): void { function notify(wc: WebContents, title: string, body: string, incognito = false): void {
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
const n = new Notification({ title, body }) // L136: an incognito ("private") download must not leak its title/URL into an OS
// toast (it persists in the Windows Action Center). Callers pass a generic outcome
// title for the private case; here we additionally drop the detail body.
const n = new Notification({ title, body: incognito ? '' : body, icon: getAppIconImage() })
n.on('click', () => { n.on('click', () => {
if (wc.isDestroyed()) return if (wc.isDestroyed()) return
const win = BrowserWindow.fromWebContents(wc) const win = BrowserWindow.fromWebContents(wc)
@@ -120,9 +107,17 @@ function notify(wc: WebContents, title: string, body: string): void {
} }
}) })
n.show() n.show()
// W10: flash the taskbar button for attention when the event happened in the
// background (window unfocused/minimized/hidden). Windows stops the flash on its
// own once the window gets focus, so there's no explicit stop to manage.
const flashWin = BrowserWindow.fromWebContents(wc)
if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true)
} }
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void { function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
// L136: incognito downloads are never recorded — not even a failure entry, which
// would persist the title + URL in the user-facing diagnostics log.
if (opts.incognito) return
addErrorLog({ addErrorLog({
id: opts.id, id: opts.id,
title, title,
@@ -135,37 +130,34 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
// --- Best-effort metadata probe (runs alongside the download) --------------- // --- Best-effort metadata probe (runs alongside the download) ---------------
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> { // Unit Separator (0x1F): a control char that can't appear in a title/uploader,
return new Promise((resolve) => { // so it safely delimits the three fields in ONE --print template. Splitting on
execFile( // newlines instead (B4) would mis-assign channel/duration whenever a title
ytdlp, // itself contains a newline, since each --print field is emitted on its own line.
[ const META_SEP = '\u001f'
'--no-playlist',
'--no-warnings', async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
'--skip-download', const r = await execFileAsync(
'--print', ytdlp,
'title', [
'--print', '--no-playlist',
'uploader', '--no-warnings',
'--print', '--skip-download',
'duration_string', '--print',
'--', `%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
url '--',
], url
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 }, ],
(err, stdout) => { { maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS }
if (err) return resolve(null) )
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim()) if (!r.ok) return null
const clean = (v?: string): string | undefined => const [title, uploader, duration] = r.stdout.split(META_SEP).map((l) => l.trim())
v && v !== 'NA' ? v : undefined const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
resolve({ return {
title: clean(title), title: clean(title),
channel: clean(uploader), channel: clean(uploader),
durationLabel: clean(duration) durationLabel: clean(duration)
}) }
}
)
})
} }
// --- Argv construction (shared by startDownload and the command preview) --- // --- Argv construction (shared by startDownload and the command preview) ---
@@ -175,6 +167,9 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just // (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. // in the renderer UI, so the renderer can't be trusted to apply it.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] { function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
// Gate the file read: listTemplates() parses templates.json on every call, so
// skip it entirely when the feature is off (PERF2).
if (!settings.customCommandEnabled) return []
return selectExtraArgs({ return selectExtraArgs({
customCommandEnabled: settings.customCommandEnabled, customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs, perDownloadExtraArgs: opts.extraArgs,
@@ -184,8 +179,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
}) })
} }
/** Resolve settings + per-download overrides into the full yt-dlp argv. */ /** Resolve settings + per-download overrides into the full yt-dlp argv.
export function buildCommand(opts: StartDownloadOptions): string[] { * `cookiesFile` is the transient decrypted jar the caller materialises for the
* 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
const settings = getSettings() const settings = getSettings()
// Output dir resolution: a per-download override wins, then the user's explicit // 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 // per-kind folder (Settings → Video/Audio folder), and finally — when that's
@@ -210,17 +207,13 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped // Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
// into resources/bin — the toggle shouldn't turn into a hard error. // into resources/bin — the toggle shouldn't turn into a hard error.
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
// Same idea: 'login' cookies only apply once the sign-in window has actually
// exported a file; otherwise the download proceeds cookie-less rather than failing.
const cookiesFile =
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
? getCookiesFilePath()
: undefined
const access = { const access = {
proxy: settings.proxy, proxy: settings.proxy,
rateLimit: settings.rateLimit, rateLimit: settings.rateLimit,
aria2cPath, aria2cPath,
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined, // L136/M6: incognito attaches no cookies from the browser either.
cookiesFromBrowser:
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
cookiesFile, cookiesFile,
restrictFilenames: settings.restrictFilenames, restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined, downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
@@ -228,7 +221,7 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
youtubePoToken: settings.youtubePoToken youtubePoToken: settings.youtubePoToken
} }
const extraArgs = resolveExtraArgs(opts, settings) const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs) return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), access, extraArgs })
} }
/** Build the exact command line for the current form state, without running it. */ /** Build the exact command line for the current form state, without running it. */
@@ -250,10 +243,7 @@ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult
// --- Public API ------------------------------------------------------------- // --- Public API -------------------------------------------------------------
export function startDownload( export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
// Self-heal the managed copy from the bundled seed before spawning, so a // Self-heal the managed copy from the bundled seed before spawning, so a
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download. // never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
ensureManagedYtdlp() ensureManagedYtdlp()
@@ -261,7 +251,7 @@ export function startDownload(
if (!existsSync(ytdlp)) { if (!existsSync(ytdlp)) {
return { return {
ok: false, ok: false,
error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).` error: YTDLP_MISSING_MSG
} }
} }
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/ // ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
@@ -275,9 +265,7 @@ export function startDownload(
if (missingBins.length > 0) { if (missingBins.length > 0) {
return { return {
ok: false, ok: false,
error: error: `${missingBins.join(' and ')} not found in ${getBinDir()}. Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
`${missingBins.join(' and ')} not found in ${getBinDir()}\n` +
`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,
@@ -301,10 +289,34 @@ export function startDownload(
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' } return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
} }
// Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
// yt-dlp reads --cookies once at startup; we delete it the moment the download
// settles, so the plaintext never lingers at rest.
let cookiesFile: string | undefined
// L136/M6: an incognito download attaches no saved login cookies (the UI promises
// "no cookies"), so it can't be tied to the user's signed-in identity.
if (!opts.incognito && getSettings().cookieSource === 'login' && hasStoredCookies()) {
// Unique per spawn (not just per id): a retry/resume reuses opts.id, and the
// superseded download's cleanupCookies() must not unlink the new spawn's jar.
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`)
if (materializeCookies(tmp)) cookiesFile = tmp
}
function cleanupCookies(): void {
if (cookiesFile) {
try {
unlinkSync(cookiesFile)
} catch {
/* best-effort */
}
cookiesFile = undefined
}
}
let child: ChildProcess let child: ChildProcess
try { try {
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true }) child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true })
} catch (e) { } catch (e) {
cleanupCookies()
return { ok: false, error: (e as Error).message } return { ok: false, error: (e as Error).message }
} }
@@ -324,66 +336,162 @@ export function startDownload(
// it in parallel so the card fills in quickly. // it in parallel so the card fills in quickly.
probeMeta(ytdlp, opts.url).then((meta) => { probeMeta(ytdlp, opts.url).then((meta) => {
if (meta?.title) resolvedTitle = meta.title if (meta?.title) resolvedTitle = meta.title
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta }) if (active.has(opts.id)) {
// Always emit a meta event: on success it fills in title/channel/duration;
// on failure (null from timeout/error) the renderer clears the
// "Resolving…" placeholder via applyEvent('meta') (SR6 / L47).
send(wc, { type: 'meta', id: opts.id, meta: meta ?? {} })
}
}) })
} }
let stdoutBuf = '' // Wire the child's streams + teardown (CL3). resolvedTitle is passed as a getter
// because the parallel probeMeta above may fill it in after this returns.
wireChildProcess({ wc, opts, rec, cleanup: cleanupCookies, getTitle: () => resolvedTitle })
return { ok: true }
}
// --- Child-process wiring ---------------------------------------------------
/**
* Wire a spawned yt-dlp child's stdout/stderr/close/error to download events, and
* run the B1 idle watchdog. Extracted from startDownload so that function reads as
* a linear spawn + pre-flight and this owns the streaming/teardown lifecycle (CL3).
*
* `getTitle` is read lazily on each event: the completion/error paths need the
* best title known *at settle time*, which the parallel metadata probe may only
* fill in after wiring is set up.
*/
function wireChildProcess(params: {
wc: WebContents
opts: StartDownloadOptions
rec: ActiveDownload
cleanup: () => void
getTitle: () => string | undefined
}): void {
const { wc, opts, rec, cleanup, getTitle } = params
const child = rec.child
let stderrTail = '' let stderrTail = ''
let filePath: string | undefined let filePath: string | undefined
// Latched once the first download stream reports 'finished'; flags later
// progress as the merge/post-processing "finishing" phase (SR7).
let finishing = false
// 'error' and 'close' can both fire for one process; only act on the first. // 'error' and 'close' can both fire for one process; only act on the first.
let settled = false let settled = false
child.stdout?.on('data', (chunk: Buffer) => { // Idle watchdog (B1): reset on any output; if it ever fires, the child has been
stdoutBuf += chunk.toString() // silent for STALL_TIMEOUT_MS, so kill + error it and free the slot.
let nl: number let stallTimer: ReturnType<typeof setTimeout> | null = null
while ((nl = stdoutBuf.indexOf('\n')) >= 0) { function clearWatchdog(): void {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '') if (stallTimer) {
stdoutBuf = stdoutBuf.slice(nl + 1) clearTimeout(stallTimer)
if (line.startsWith('prog|')) { stallTimer = null
const p = parseProgress(line.slice('prog|'.length))
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
} else if (line.startsWith('path|')) {
filePath = line.slice('path|'.length).trim()
}
} }
}
function bumpWatchdog(): void {
clearWatchdog()
stallTimer = setTimeout(() => {
// A cancel/pause races the timer: it already released the slot and the
// child's close stays silent, so don't fire a spurious stall error (mirrors
// the canceled/paused guards on the close handler below).
if (settled || rec.canceled || rec.paused) return
settled = true
clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
killTree(rec)
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, getTitle(), msg)
notify(
wc,
opts.incognito ? 'Download stopped' : (getTitle() ?? 'Download failed'),
msg,
opts.incognito
)
}, STALL_TIMEOUT_MS)
}
bumpWatchdog()
// Split stdout into lines (shared helper, CC3/CC4) and parse our --print markers.
// No flush on close: yt-dlp's progress/path template lines are always
// newline-terminated, so a trailing partial is never a real marker.
const stdoutLines = createLineBuffer((line) => {
if (line.startsWith(PROGRESS_MARKER)) {
const p = parseProgress(line.slice(PROGRESS_MARKER.length))
if (p) {
// SR7: yt-dlp reports a 'finished' status when each download stream
// completes. A video+audio download has two streams, so the bar would
// otherwise fill 0→100% twice. Latch on the first 'finished' and flag
// every later tick as "finishing" so the renderer shows an indeterminate
// merge state instead of a visible restart.
if (p.status === 'finished') finishing = true
send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
}
} else if (line.startsWith(FILEPATH_MARKER)) {
filePath = line.slice(FILEPATH_MARKER.length).trim()
}
})
child.stdout?.on('data', (chunk: Buffer) => {
bumpWatchdog()
stdoutLines.push(chunk.toString())
}) })
child.stderr?.on('data', (chunk: Buffer) => { child.stderr?.on('data', (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-4000) bumpWatchdog()
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES)
}) })
child.on('error', (err) => { child.on('error', (err) => {
if (settled) return if (settled) return
settled = true settled = true
active.delete(opts.id) clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
// A paused download was killed on purpose — stay silent, like a cancel. // A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) { if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message }) send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message) logFailure(opts, getTitle(), err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message) notify(
wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
err.message,
opts.incognito
)
} }
}) })
child.on('close', (code) => { child.on('close', (code) => {
if (settled) return if (settled) return
settled = true settled = true
active.delete(opts.id) clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
// Canceled: renderer already showed 'canceled'. Paused: renderer showed // Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event. // 'paused' and keeps the .part for a later resume. Either way, no event.
if (rec.canceled || rec.paused) return if (rec.canceled || rec.paused) return
if (code === 0) { if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath }) send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.') notify(
wc,
opts.incognito ? 'Download complete' : (getTitle() ?? 'Download complete'),
'Finished downloading.',
opts.incognito
)
} else { } else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}` const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg }) send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, resolvedTitle, msg) logFailure(opts, getTitle(), msg)
notify(wc, resolvedTitle ?? 'Download failed', msg) notify(
wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
msg,
opts.incognito
)
} }
}) })
return { ok: true }
} }
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve // Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
@@ -407,6 +515,11 @@ export function cancelDownload(id: string): void {
const rec = active.get(id) const rec = active.get(id)
if (!rec) return if (!rec) return
rec.canceled = true rec.canceled = true
// Free the slot now (not on the async 'close') so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard — or run briefly over the
// cap — while taskkill tears down this tree. The child's later 'close' stays
// silent (rec.canceled) and releaseActive() no-ops. (L148)
active.delete(id)
killTree(rec) killTree(rec)
} }
@@ -421,5 +534,9 @@ export function pauseDownload(id: string): void {
const rec = active.get(id) const rec = active.get(id)
if (!rec) return if (!rec) return
rec.paused = true rec.paused = true
// Free the slot now (see cancelDownload / L148). The partial .part stays on disk
// for a later resume, which reuses this id — releaseActive()'s identity check
// keeps the doomed child's 'close' from evicting that fresh spawn. (L140)
active.delete(id)
killTree(rec) killTree(rec)
} }
+10 -32
View File
@@ -1,45 +1,23 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { ErrorLogEntry } from '@shared/ipc' import type { ErrorLogEntry } from '@shared/ipc'
import { isValidErrorLogEntry } from './validation' import { isValidErrorLogEntry } from './validation'
import { createJsonStore } from './jsonStore'
import { ERRORLOG_MAX_ENTRIES } from './constants'
// Plain JSON in userData, same shape as history.ts. Persisted so a failure // Plain JSON in userData, same shape as history.ts. Persisted so a failure
// report survives the queue item being cleared (Seal's "debug report"). // report survives the queue item being cleared (Seal's "debug report"). Atomic
const MAX_ENTRIES = 200 // writes / corruption backup / caching come from the shared jsonStore (R1R3).
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, ERRORLOG_MAX_ENTRIES)
function errorLogFile(): string {
return join(app.getPath('userData'), 'errorlog.json')
}
// Per-entry validation (isValidErrorLogEntry, in validation.ts) so a hand-edited
// or corrupted errorlog.json can't feed the UI entries with the wrong shape —
// invalid rows are dropped. (audit S5)
export function listErrorLog(): ErrorLogEntry[] { export function listErrorLog(): ErrorLogEntry[] {
try { return store.read()
if (!existsSync(errorLogFile())) return []
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
} catch {
return []
}
}
function save(entries: ErrorLogEntry[]): void {
try {
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted error log */
}
} }
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] { export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
const entries = [entry, ...listErrorLog()] return store.write([entry, ...listErrorLog()])
save(entries)
return entries
} }
export function clearErrorLog(): ErrorLogEntry[] { export function clearErrorLog(): ErrorLogEntry[] {
save([]) return store.write([])
return []
} }
+9 -14
View File
@@ -1,6 +1,7 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { getFfmpegPath, getFfprobePath } from './binaries' import { getFfmpegPath, getFfprobePath } from './binaries'
import { execFileAsync } from './lib/exec'
import { VERSION_TIMEOUT_MS } from './constants'
import type { FfmpegVersionResult } from '@shared/ipc' import type { FfmpegVersionResult } from '@shared/ipc'
/** /**
@@ -11,19 +12,13 @@ import type { FfmpegVersionResult } from '@shared/ipc'
* out, or the line doesn't parse — Settings then shows "not found" instead of * out, or the line doesn't parse — Settings then shows "not found" instead of
* failing. Unlike yt-dlp these are never self-updated, so there's no update path. * failing. Unlike yt-dlp these are never self-updated, so there's no update path.
*/ */
function readToolVersion(path: string): Promise<string | null> { async function readToolVersion(path: string): Promise<string | null> {
if (!existsSync(path)) return Promise.resolve(null) if (!existsSync(path)) return null
return new Promise((resolve) => { const r = await execFileAsync(path, ['-version'], { timeout: VERSION_TIMEOUT_MS })
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => { if (!r.ok) return null
if (err) { const firstLine = r.stdout.split('\n', 1)[0] ?? ''
resolve(null) const m = firstLine.match(/version\s+(\S+)/i)
return return m?.[1] ?? null
}
const firstLine = stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
resolve(m ? m[1] : null)
})
})
} }
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */ /** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
+20 -40
View File
@@ -1,59 +1,39 @@
import { app } from 'electron' import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation' import { isValidHistoryEntry } from './validation'
import { createJsonStore } from './jsonStore'
// Plain JSON in userData (portable build redirects userData next to the exe). // Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later. // Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic
const MAX_ENTRIES = 500 // writes, corruption backup, and caching come from the shared jsonStore (R1R3).
// The row cap (HISTORY_MAX_ENTRIES) is shared with the renderer's optimistic list.
//
// Per-entry validation (isValidHistoryEntry) so a hand-edited or corrupted
// history.json can't feed the UI (or openPath) entries with the wrong shape —
// invalid rows are dropped rather than trusted. (audit S5)
const store = createJsonStore('history.json', isValidHistoryEntry, HISTORY_MAX_ENTRIES)
function historyFile(): string {
return join(app.getPath('userData'), 'history.json')
}
// Per-entry validation (isValidHistoryEntry, in validation.ts) so a hand-edited
// or corrupted history.json can't feed the UI (or openPath) entries with the
// wrong shape — invalid rows are dropped rather than trusted. (audit S5)
export function listHistory(): HistoryEntry[] { export function listHistory(): HistoryEntry[] {
try { return store.read()
if (!existsSync(historyFile())) return []
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
} catch {
return []
}
}
function save(entries: HistoryEntry[]): void {
try {
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted history */
}
} }
export function addHistory(entry: HistoryEntry): HistoryEntry[] { export function addHistory(entry: HistoryEntry): HistoryEntry[] {
// De-dupe by id (a retry of the same item replaces its prior entry). // De-dupe by id AND url (M35): a History re-download re-queues via addFromUrl,
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)] // which mints a NEW id, so id-only de-dup would let the same video accumulate a
save(entries) // fresh row on every re-download. Dropping any prior entry with the same url
return entries // keeps one row per video, refreshed to the top.
const prior = listHistory().filter((e) => e.id !== entry.id && e.url !== entry.url)
return store.write([entry, ...prior])
} }
export function removeHistory(id: string): HistoryEntry[] { export function removeHistory(id: string): HistoryEntry[] {
const entries = listHistory().filter((e) => e.id !== id) return store.write(listHistory().filter((e) => e.id !== id))
save(entries)
return entries
} }
export function removeManyHistory(ids: string[]): HistoryEntry[] { export function removeManyHistory(ids: string[]): HistoryEntry[] {
const remove = new Set(ids) const remove = new Set(ids)
const entries = listHistory().filter((e) => !remove.has(e.id)) return store.write(listHistory().filter((e) => !remove.has(e.id)))
save(entries)
return entries
} }
export function clearHistory(): HistoryEntry[] { export function clearHistory(): HistoryEntry[] {
save([]) return store.write([])
return []
} }
+85 -234
View File
@@ -1,55 +1,28 @@
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron' import { app, shell, BrowserWindow, nativeTheme, Notification, Menu } from 'electron'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { IpcChannels } from '@shared/ipc'
import { PAGE_BACKGROUND } from '@shared/theme'
import { import {
IpcChannels, registerIpcHandlers,
type StartDownloadOptions, resolveBackgroundMode,
type Settings, applyNativeTheme,
type HistoryEntry, getSystemThemeInfo
type CommandTemplate, } from './ipc'
type YtdlpUpdateChannel, import { runStartupYtdlpAutoUpdate } from './ytdlp'
type SystemThemeInfo, import { getAppIconImage } from './binaries'
type TaskbarProgress import { hasActiveDownloads } from './download'
} from '@shared/ipc' import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp' import { ensureMediaDirs } from './paths'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import {
startDownload,
cancelDownload,
pauseDownload,
previewCommand,
hasActiveDownloads
} from './download'
import { runTerminal, cancelTerminal } from './terminal'
import {
getSettings,
setSettings,
ensureMediaDirs,
applyLaunchAtStartup,
migrateSecretsAtRest
} from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable' import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal' import { migrateLegacyCookies } from './cookies'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies' import { attachEditContextMenu } from './contextMenu'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog' import { flushAllStores } from './jsonStore'
import { exportBackup, importBackup } from './backup'
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink' import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
import { import { isSyncLaunch } from './schedule'
listSources,
getSource,
removeSource,
listMediaItems,
setMediaItemDownloaded,
setSourceWatched
} from './sources'
import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
import { createTray, markQuitting, isQuitting } from './tray' import { createTray, markQuitting, isQuitting } from './tray'
import { logger } from './logger'
import { initialWindowState, saveWindowState } from './windowState'
// Only one instance ever runs. A second launch — e.g. the OS invoking us again // 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 // for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
@@ -79,34 +52,15 @@ setupPortableData()
// installer also declares this scheme (electron-builder.yml's `protocols`) // installer also declares this scheme (electron-builder.yml's `protocols`)
// so it's registered even before first launch; this call additionally covers // so it's registered even before first launch; this call additionally covers
// the portable build and dev, which have no installer step to do it for us. // the portable build and dev, which have no installer step to do it for us.
if (is.dev && process.argv.length >= 2) { const devScript = process.argv[1]
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])]) if (is.dev && devScript) {
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)])
} else { } else {
app.setAsDefaultProtocolClient('aerofetch') app.setAsDefaultProtocolClient('aerofetch')
} }
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
// Page background per theme — keep in sync with `pageBackground` in
// src/renderer/src/theme.ts. Set as the window's NATIVE background so the
// one-frame compositor repaint (when a tooltip/dropdown overlay first paints on
// Windows) shows the app's current color instead of a mismatched white flash.
const THEME_BACKGROUND = { light: '#f7f7f8', dark: '#161618' } as const
// 'system' isn't a real background — resolve it against the OS's current
// preference (nativeTheme.themeSource defaults to 'system', so this tracks it
// without AeroFetch ever touching themeSource itself).
function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
}
function getSystemThemeInfo(): SystemThemeInfo {
return {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
}
}
// Tell the user (once per run) that closing the window left AeroFetch running so // Tell the user (once per run) that closing the window left AeroFetch running so
// an in-progress download could finish — shown only when they haven't already // an in-progress download could finish — shown only when they haven't already
// opted into tray mode, so a window that "won't close" doesn't read as a bug. // opted into tray mode, so a window that "won't close" doesn't read as a bug.
@@ -116,7 +70,8 @@ function notifyBackgroundOnce(): void {
notifiedBackground = true notifiedBackground = true
new Notification({ new Notification({
title: 'AeroFetch is still running', title: 'AeroFetch is still running',
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.' body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
icon: getAppIconImage()
}).show() }).show()
} }
@@ -140,11 +95,22 @@ const DENIED_PERMISSIONS = new Set([
]) ])
function createWindow(): void { function createWindow(): void {
// Reopen where the user left off (size / position / maximized), falling back to the
// default size when there's no usable saved state — first run, or the saved monitor
// is no longer connected (W2 / UX19).
const ws = initialWindowState()
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 920, title: 'AeroFetch',
height: 700, width: ws.width,
height: ws.height,
x: ws.x,
y: ws.y,
// Below this the 212px sidebar + content layout breaks; pin a sensible
// floor so the window can't be dragged down to unusable widths (W1).
minWidth: 640,
minHeight: 480,
show: false, show: false,
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)], backgroundColor: PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.cjs'), preload: join(__dirname, '../preload/index.cjs'),
@@ -155,6 +121,22 @@ function createWindow(): void {
}) })
mainWindow = win mainWindow = win
if (ws.maximized) win.maximize()
// Persist size/position/maximized so the next launch restores them (W2). Debounced
// so a drag-resize writes once when it settles; also saved on close (which may only
// hide to tray) to capture the final state.
let saveTimer: ReturnType<typeof setTimeout> | null = null
const scheduleSave = (): void => {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => saveWindowState(win), 500)
}
win.on('resize', scheduleSave)
win.on('move', scheduleSave)
// Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4).
attachEditContextMenu(win.webContents)
win.on('ready-to-show', () => { win.on('ready-to-show', () => {
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so // A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
// the daily background sync doesn't steal focus; a normal launch shows + focuses. // the daily background sync doesn't steal focus; a normal launch shows + focuses.
@@ -167,6 +149,8 @@ function createWindow(): void {
// kill the spawned yt-dlp processes and lose the download. A real quit (tray // kill the spawned yt-dlp processes and lose the download. A real quit (tray
// menu / before-quit) sets isQuitting() so this lets the close through. // menu / before-quit) sets isQuitting() so this lets the close through.
win.on('close', (e) => { win.on('close', (e) => {
// Capture the final bounds even when the close only hides to tray (W2).
saveWindowState(win)
if (isQuitting()) return if (isQuitting()) return
const downloadsRunning = hasActiveDownloads() const downloadsRunning = hasActiveDownloads()
if (getSettings().minimizeToTray || downloadsRunning) { if (getSettings().minimizeToTray || downloadsRunning) {
@@ -183,6 +167,13 @@ function createWindow(): void {
if (mainWindow === win) mainWindow = null if (mainWindow === win) mainWindow = null
}) })
// When the user re-opens the window (via tray, second-instance, or deeplink)
// reset the background-notify latch so they're informed again if they close
// while a download is still running (L68).
win.on('show', () => {
notifiedBackground = false
})
// The OS may have launched us with an aerofetch:// link or a "Send to" .url // The OS may have launched us with an aerofetch:// link or a "Send to" .url
// file on the command line — hand it to DownloadBar's link-suggestion banner // file on the command line — hand it to DownloadBar's link-suggestion banner
// once the page (and its IPC listener) is actually ready to receive it. // once the page (and its IPC listener) is actually ready to receive it.
@@ -225,161 +216,12 @@ function createWindow(): void {
} }
} }
function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
downloadAppUpdate(url, e.sender)
)
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
const result = startDownload(e.sender, opts)
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
// download.ts's own close/error handlers, so log them here instead.
if (!result.ok) {
addErrorLog({
id: opts.id,
url: opts.url,
kind: opts.kind,
error: result.error ?? 'Unknown error',
occurredAt: Date.now()
})
}
return result
})
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
runTerminal(e.sender, id, args)
)
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const res = await dialog.showOpenDialog(win!, {
properties: ['openDirectory', 'createDirectory']
})
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
const result = setSettings(partial)
// Keep the window's native background in sync with the theme so a compositor
// repaint never flashes a mismatched color behind an overlay. Use the
// validated result, not the raw partial (which may hold a bogus value).
if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
)
}
return result
})
ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo())
ipcMain.handle(IpcChannels.openHighContrastSettings, () =>
shell.openExternal('ms-settings:easeofaccess-highcontrast')
)
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
previewCommand(opts)
)
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
ipcMain.handle(IpcChannels.backupExport, (e) =>
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
ipcMain.handle(IpcChannels.backupImport, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const result = await importBackup(win)
// A restored backup may have changed the theme; keep the native window
// background in sync the same way settingsSet does.
if (result.ok) {
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)])
}
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))
// Reflect overall queue progress on the Windows taskbar (fire-and-forget).
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
if (!mainWindow || mainWindow.isDestroyed()) return
if (p.mode === 'none') mainWindow.setProgressBar(-1)
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
})
}
// Push OS theme/contrast changes to every window, and keep the native // Push OS theme/contrast changes to every window, and keep the native
// background in sync for windows currently following 'system'. // background in sync for windows currently following 'system'.
function registerSystemThemeBridge(): void { function registerSystemThemeBridge(): void {
nativeTheme.on('updated', () => { nativeTheme.on('updated', () => {
const info = getSystemThemeInfo() const info = getSystemThemeInfo()
const bg = THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)] const bg = PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IpcChannels.systemThemeUpdate, info) win.webContents.send(IpcChannels.systemThemeUpdate, info)
if (getSettings().theme === 'system') win.setBackgroundColor(bg) if (getSettings().theme === 'system') win.setBackgroundColor(bg)
@@ -400,6 +242,10 @@ if (isPrimaryInstance) {
app.whenReady().then(() => { app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app') electronApp.setAppUserModelId('com.aerofetch.app')
// M31: suppress the default Electron menu (which exposes DevTools/Reload via
// Alt) in production builds. In dev the menu is kept so DevTools are accessible.
if (!is.dev) Menu.setApplicationMenu(null)
// Create the default Documents\Video and Documents\Audio destinations so they // Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind). // exist from first launch (downloads are routed into them by kind).
ensureMediaDirs() ensureMediaDirs()
@@ -407,6 +253,9 @@ if (isPrimaryInstance) {
// Encrypt any credential still stored as legacy plaintext (from before at-rest // Encrypt any credential still stored as legacy plaintext (from before at-rest
// encryption), once safeStorage is available post-ready. // encryption), once safeStorage is available post-ready.
migrateSecretsAtRest() migrateSecretsAtRest()
// Same for a pre-encryption plaintext cookies.txt — encrypt it and delete the
// plaintext so a logged-in session no longer sits in the open (H7).
migrateLegacyCookies()
// Sync the Windows "run at sign-in" entry with the persisted setting, so it // Sync the Windows "run at sign-in" entry with the persisted setting, so it
// reflects the user's choice even if they changed it on another install. // reflects the user's choice even if they changed it on another install.
@@ -416,9 +265,11 @@ if (isPrimaryInstance) {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
registerIpcHandlers() registerIpcHandlers(() => mainWindow)
registerSystemThemeBridge() registerSystemThemeBridge()
registerSendToShortcut() registerSendToShortcut()
// Apply the persisted theme to the OS title bar before the window opens (W3).
applyNativeTheme(getSettings().theme)
createWindow() createWindow()
createTray(() => mainWindow) createTray(() => mainWindow)
@@ -443,20 +294,20 @@ if (isPrimaryInstance) {
if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status) mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
} }
}).catch(() => {}) }).catch((e) => logger.error('yt-dlp auto-update failed', e))
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
}) })
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set // Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
// the quitting flag so the window's close handler exits instead of hiding to tray. // the quitting flag so the window's close handler exits instead of hiding to tray.
app.on('before-quit', () => markQuitting()) // Also flush any debounced JSON-store writes synchronously so a quit mid-debounce
// can't drop the last history/sources/template change (R3).
app.on('window-all-closed', () => { app.on('before-quit', () => {
if (process.platform !== 'darwin') { markQuitting()
app.quit() flushAllStores()
}
}) })
// Windows-only app: closing the last window quits (the tray/in-flight-download
// paths hide rather than close, so this only fires on a real exit). The former
// macOS 'activate' handler and darwin guard were dead branches here (L147).
app.on('window-all-closed', () => app.quit())
} }
+23 -25
View File
@@ -9,9 +9,10 @@
* this module is the impure shell that spawns yt-dlp and writes to disk. * this module is the impure shell that spawns yt-dlp and writes to disk.
*/ */
import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries' import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { execFileAsync } from './lib/exec'
import { INDEX_MAX_BUFFER, INDEX_TIMEOUT_MS } from './constants'
import { cleanError } from './log' import { cleanError } from './log'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { import {
@@ -45,28 +46,24 @@ interface FlatInfo {
* flat upload list can be a few MB of JSON; the timeout is generous for the same * 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. * reason. `--` terminates option parsing so the URL can't be read as a flag.
*/ */
function probeFlat(url: string): Promise<FlatInfo> { async function probeFlat(url: string): Promise<FlatInfo> {
return new Promise((resolve, reject) => { const r = await execFileAsync(
execFile( getYtdlpPath(),
getYtdlpPath(), ['-J', '--flat-playlist', '--no-warnings', '--', url],
['-J', '--flat-playlist', '--no-warnings', '--', url], { maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS }
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 }, )
(err, stdout, stderr) => { if (!r.ok) {
if (err) { throw new Error(
const msg = (err as { killed?: boolean }).killed r.timedOut
? 'Timed out indexing source. Check the link or your connection.' ? 'Timed out indexing source. Check the link or your connection.'
: cleanError(stderr) || err.message : cleanError(r.stderr) || r.error?.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.'))
}
}
) )
}) }
try {
return JSON.parse(r.stdout) as FlatInfo
} catch {
throw new Error('Could not parse source info from yt-dlp.')
}
} }
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */ /** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
@@ -90,7 +87,7 @@ export async function indexSource(
return { ok: false, error: (e as Error).message } return { ok: false, error: (e as Error).message }
} }
if (!existsSync(getYtdlpPath())) { if (!existsSync(getYtdlpPath())) {
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' } return { ok: false, error: YTDLP_MISSING_MSG }
} }
const cls = classifySource(url) const cls = classifySource(url)
@@ -152,7 +149,8 @@ export async function indexSource(
if (entries.length === 0) { if (entries.length === 0) {
return { return {
ok: false, ok: false,
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.' error:
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
} }
} }
title = data.title || 'Playlist' title = data.title || 'Playlist'
+5 -2
View File
@@ -88,7 +88,10 @@ export function classifySource(raw: string): SourceClass | null {
// A playlist is identified purely by its list= param (works on any youtube host). // A playlist is identified purely by its list= param (works on any youtube host).
const list = u.searchParams.get('list') const list = u.searchParams.get('list')
if (isYouTube && list) { if (isYouTube && list) {
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` } return {
kind: 'playlist',
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
}
} }
if (!isYouTube) return null if (!isYouTube) return null
@@ -163,7 +166,7 @@ export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = [] const ids: string[] = []
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
let m: RegExpExecArray | null let m: RegExpExecArray | null
while ((m = re.exec(xml)) !== null) ids.push(m[1]) while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
return ids return ids
} }
+291
View File
@@ -0,0 +1,291 @@
/**
* IPC surface for the main process. Every `ipcMain.handle` (and the one
* fire-and-forget `taskbarProgress` receiver) lives here, extracted from index.ts
* so that module is just app lifecycle + window creation and this is the single
* place the renderer's IPC contract is wired (L2).
*
* The handful of handlers that must touch the window (folder picker parenting,
* theme-synced background, taskbar progress) get the current window through the
* `getMainWindow` accessor rather than a captured reference, so they always act on
* the live window. The small theme helpers they share are defined and exported
* here too, since index's system-theme bridge uses them as well.
*/
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { existsSync } from 'fs'
import {
IpcChannels,
type StartDownloadOptions,
type Settings,
type HistoryEntry,
type CommandTemplate,
type YtdlpUpdateChannel,
type SystemThemeInfo,
type TaskbarProgress,
type PersistedQueueItem
} from '@shared/ipc'
import { PAGE_BACKGROUND } from '@shared/theme'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { runTerminal, cancelTerminal } from './terminal'
import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { listQueue, saveQueue } from './queue'
import { exportBackup, importBackup } from './backup'
import {
listSources,
getSource,
removeSource,
listMediaItems,
setMediaItemDownloaded,
setSourceWatched
} from './sources'
import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync } from './schedule'
import { getActiveBadge, getErrorBadge } from './badge'
import { openPoTokenWindow } from './poToken'
import { logger } from './logger'
// --- Theme helpers (shared with index's system-theme bridge) ----------------
/** Resolve 'system' against the OS preference so callers always get a concrete color. */
export function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
}
/**
* Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting
* themeSource to 'light'/'dark' forces the caption/chrome to match; 'system'
* restores OS control. Called at startup and on every theme change.
*/
export function applyNativeTheme(theme: Settings['theme']): void {
nativeTheme.themeSource = theme
}
export function getSystemThemeInfo(): SystemThemeInfo {
return {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
}
}
// --- IPC registration -------------------------------------------------------
export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): void {
// M30: a broken contextBridge causes the renderer to silently run in preview/mock
// mode. Catch it here and show a hard error so the failure is never invisible.
ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => {
dialog.showErrorBox(
'AeroFetch could not start',
`The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}`
)
app.quit()
})
// CC8: persist renderer-side failures (the M29 `logError` sites) to the main log
// file — the renderer console is unreachable in a packaged build. Fire-and-forget.
ipcMain.on(IpcChannels.logWrite, (_e, op: string, detail: string) =>
logger.error(`[renderer] ${op}`, detail)
)
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
downloadAppUpdate(url, e.sender)
)
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
const result = startDownload(e.sender, opts)
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
// download.ts's own close/error handlers, so log them here instead — unless the
// download is incognito, which is never recorded (L136).
if (!result.ok && !opts.incognito) {
addErrorLog({
id: opts.id,
url: opts.url,
kind: opts.kind,
error: result.error ?? 'Unknown error',
occurredAt: Date.now()
})
}
return result
})
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
runTerminal(e.sender, id, args)
)
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
// Seed the picker at the currently-configured folder so it opens where the
// user already points, not a generic default (W5). 'createDirectory' is a
// macOS-only property and a no-op on Windows, so it's dropped (L58).
const defaultPath = current && existsSync(current) ? current : undefined
// Use the parented overload only when we actually have a window — passing a
// forced non-null window that's gone can throw (L53).
const res = win
? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath })
: await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath })
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
ipcMain.handle(IpcChannels.openUrl, (_e, url: string) => {
try {
const { protocol } = new URL(url)
if (protocol === 'http:' || protocol === 'https:') void shell.openExternal(url)
} catch {
// Ignore malformed URLs.
}
})
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
const result = setSettings(partial)
// Keep the window's native background and title bar in sync with the theme
// so a compositor repaint never flashes a mismatched color, and the OS-drawn
// caption always matches the in-app theme (W3). Use the validated result.
if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
PAGE_BACKGROUND[resolveBackgroundMode(result.theme)]
)
applyNativeTheme(result.theme)
}
return result
})
ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo())
ipcMain.handle(IpcChannels.openHighContrastSettings, () =>
shell.openExternal('ms-settings:easeofaccess-highcontrast')
)
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) =>
// Parent the sign-in window to the app window (W6) so it groups under
// AeroFetch instead of spawning a second taskbar button.
openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) =>
saveTemplate(template)
)
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
previewCommand(opts)
)
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
// Persisted download queue (M4): the renderer mirrors its durable items here and
// rehydrates them on launch, so saved/scheduled/pending downloads survive a quit.
ipcMain.handle(IpcChannels.queueList, () => listQueue())
ipcMain.handle(IpcChannels.queueSave, (_e, items: PersistedQueueItem[]) => saveQueue(items))
ipcMain.handle(IpcChannels.backupExport, (e) =>
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
ipcMain.handle(IpcChannels.backupImport, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const result = await importBackup(win)
// A restored backup may have changed the theme; keep the native window
// background and title bar in sync the same way settingsSet does (W3).
if (result.ok) {
const theme = getSettings().theme
win?.setBackgroundColor(PAGE_BACKGROUND[resolveBackgroundMode(theme)])
applyNativeTheme(theme)
}
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))
// Reflect overall queue progress on the Windows taskbar and window title (SR8).
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
const win = getMainWindow()
if (!win || win.isDestroyed()) return
if (p.mode === 'none') {
win.setProgressBar(-1)
win.setOverlayIcon(null, '')
win.setTitle('AeroFetch')
} else {
win.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge()
const n = p.badgeCount ?? 0
const label =
p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active`
win.setOverlayIcon(badge, label)
win.setTitle(p.mode === 'error' ? 'AeroFetch — Error' : `AeroFetch — ${label}`)
}
})
// Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P).
ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => {
const token = await openPoTokenWindow()
if (token) await setSettings({ youtubePoToken: token })
return token
})
}
+151
View File
@@ -0,0 +1,151 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, unlinkSync } from 'fs'
import { logger } from './logger'
/**
* One shared persistence layer for the hand-rolled JSON array stores (history,
* error log, templates, sources, media-items). Replaces the per-module
* read/parse/write copies (SIMP1) and fixes three data-safety blockers:
*
* - R1 -- atomic writes: write a temp file then `rename` over the target, so a
* crash / power-cut mid-write can never truncate the real file. (`electron-store`,
* used for settings, is already atomic; these stores were not.)
* - R2 -- corruption is no longer silent data loss: on a parse error the bad file
* is copied aside (`<file>.corrupt-<ts>`) before we fall back to empty, so a
* single bad byte can't wipe the user's history/sources from their perspective.
* - R3 -- an in-memory cache + debounced batched writes: the previous code
* re-read + re-parsed the entire (multi-MB) file and rewrote it synchronously
* on *every* download completion (≈O(n²) over a large channel, blocking the
* main thread). Reads now hit the cache; writes coalesce into one atomic flush.
*/
// Debounce window for batched writes. Long enough to coalesce a burst (e.g. many
// items enqueued/marked at once), short enough that data lands on disk promptly.
const FLUSH_DELAY_MS = 400
/**
* Read + validate a JSON array from `path`. Invalid rows are dropped. On a parse
* error the file is backed up to `<path>.corrupt-<timestamp>` before returning []
* so corruption is recoverable rather than a silent wipe (R2). A missing file is
* simply [] (first run), with no backup.
*/
export function readJsonArraySafe<T>(path: string, isValid: (o: unknown) => o is T): T[] {
let raw: string
try {
if (!existsSync(path)) return []
raw = readFileSync(path, 'utf8')
} catch {
return []
}
try {
const data = JSON.parse(raw)
return Array.isArray(data) ? data.filter(isValid) : []
} catch {
try {
copyFileSync(path, `${path}.corrupt-${Date.now()}`)
} catch {
/* best-effort -- if we can't back it up, still don't crash the read */
}
return []
}
}
/**
* Atomically write `value` as JSON to `path`: serialise to `<path>.tmp`, then
* `rename` it over the target (an atomic operation on the same volume), so a
* crash mid-write leaves either the old file or the new one -- never a truncated
* one (R1). `pretty` indents for human-readable files; pass false for large
* machine-only stores to avoid inflating size/write time (R8). Returns whether the
* write landed: a failure (disk full, read-only data dir) is logged rather than
* swallowed silently, so a record that looked saved but vanished on restart is at
* least diagnosable (R6). The leftover temp file is removed on failure so a partial
* `.tmp` can't accumulate.
*/
export function writeJsonAtomic(path: string, value: unknown, pretty = true): boolean {
const tmp = `${path}.tmp`
try {
writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value))
renameSync(tmp, path)
return true
} catch (e) {
// R6: previously a silent catch — a failed persist (disk full, read-only
// profile) dropped the change with no trace. It now lands in the file-backed
// diagnostic log (CC8); the user-facing toast ties to the global status surface (UI25/UX9).
logger.error(`failed to write ${path}`, e)
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch {
/* best-effort temp cleanup */
}
return false
}
}
export interface JsonStore<T> {
/** All rows, validated; cached after the first read. */
read(): T[]
/** Replace all rows (capped), update the cache, and schedule an atomic flush. Returns the stored rows. */
write(items: T[]): T[]
/** Force any pending debounced write to disk synchronously (e.g. on quit). */
flush(): void
}
// Every store registers itself so app-quit can flush pending writes in one call.
const registry: JsonStore<unknown>[] = []
/**
* Create a cached, atomic JSON array store backed by `<userData>/<filename>`.
* `cap` bounds the row count (pass Infinity for none). The path is resolved
* lazily so importing this module never touches `app` before it's ready.
*/
export function createJsonStore<T>(
filename: string,
isValid: (o: unknown) => o is T,
cap: number,
// Pretty-print by default (small, hand-inspectable files); pass false for a
// large machine-only store to keep writes compact (R8).
pretty = true
): JsonStore<T> {
let cache: T[] | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let dirty = false
const filePath = (): string => join(app.getPath('userData'), filename)
function read(): T[] {
if (cache === null) cache = readJsonArraySafe(filePath(), isValid)
return cache
}
function flush(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
if (!dirty) return
// Only clear the dirty flag once the write actually lands. A failed flush
// (disk full, transient lock) stays dirty so the next write() or the
// quit-time flushAllStores() retries it instead of silently dropping it (R6).
if (writeJsonAtomic(filePath(), cache ?? [], pretty)) dirty = false
}
function write(items: T[]): T[] {
// Avoid an extra copy in the common under-cap case so a per-completion write
// stays O(1) rather than re-slicing the whole list each time (R3).
cache = items.length > cap ? items.slice(0, cap) : items
dirty = true
if (timer) clearTimeout(timer)
timer = setTimeout(flush, FLUSH_DELAY_MS)
return cache
}
const store: JsonStore<T> = { read, write, flush }
registry.push(store as JsonStore<unknown>)
return store
}
/** Synchronously flush every store's pending write -- call on app quit. */
export function flushAllStores(): void {
for (const s of registry) s.flush()
}
+53
View File
@@ -0,0 +1,53 @@
import { execFile, type ExecFileOptions } from 'child_process'
/**
* Normalized result of a one-shot child-process run. `ok` is true on a clean
* exit-0; `timedOut` distinguishes a kill-by-timeout (Node sets `err.killed` when
* it terminates the process on the `timeout` option) from an ordinary non-zero
* exit, so callers can show "Timed out …" versus the captured stderr.
*/
export interface ExecResult {
ok: boolean
stdout: string
stderr: string
timedOut: boolean
/** The raw error on a non-zero exit / spawn failure (undefined when ok). */
error?: Error
}
/**
* `execFile` as a promise that never rejects (audit SIMP2/CC4/CC5). Replaces the
* six hand-rolled `new Promise((resolve) => execFile(…, cb))` wrappers across
* probe / ytdlp (×2) / ffmpeg / indexer / download.probeMeta — each caller now maps
* this normalized result to its own shape instead of repeating the `err.killed`
* timeout check and the resolve/reject plumbing. (schedule.ts keeps its own wrapper:
* it needs the numeric exit code, which this helper deliberately doesn't surface.)
*
* `windowsHide` defaults on (every caller wants the console window suppressed);
* pass `timeout` / `maxBuffer` per call exactly as before.
*/
export function execFileAsync(
file: string,
args: readonly string[],
options: ExecFileOptions = {}
): Promise<ExecResult> {
return new Promise((resolve) => {
execFile(file, [...args], { windowsHide: true, ...options }, (err, stdout, stderr) => {
// With the default (utf8) encoding stdout/stderr are strings, but ExecFileOptions
// permits a Buffer encoding, so normalize defensively.
const out = typeof stdout === 'string' ? stdout : stdout.toString()
const errOut = typeof stderr === 'string' ? stderr : stderr.toString()
if (err) {
resolve({
ok: false,
stdout: out,
stderr: errOut,
timedOut: (err as { killed?: boolean }).killed === true,
error: err
})
} else {
resolve({ ok: true, stdout: out, stderr: errOut, timedOut: false })
}
})
})
}
+36
View File
@@ -0,0 +1,36 @@
import type { DownloadProgress } from '@shared/ipc'
import { fmtBytes } from '@shared/format'
// yt-dlp progress-line parsing. Extracted from download.ts so it can be
// unit-tested without pulling in the electron import chain (L37). Byte/speed/ETA
// formatting lives in @shared/format — the one home for both sides of the IPC
// boundary; consumers import it directly.
function num(s?: string): number | undefined {
if (!s || s === 'NA') return undefined
const n = Number(s)
return Number.isFinite(n) ? n : undefined
}
export function parseProgress(rest: string): DownloadProgress | null {
const parts = rest.split('|')
if (parts.length < 6) return null
const [status, dl, total, totalEst, speed, eta] = parts
const downloaded = num(dl)
const totalBytes = num(total) ?? num(totalEst)
let progress = 0
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
return {
status: status || 'downloading',
progress,
// Carry raw numbers across the IPC boundary; the renderer formats them for
// display AND aggregates them (combined speed / longest ETA) without having
// to re-parse a formatted string (H4).
speedBytesPerSec: num(speed),
etaSeconds: num(eta),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined,
// No (estimated) total → the % can never advance; flag it so the renderer
// shows an indeterminate bar rather than a stuck 0% (L137).
sizeUnknown: !totalBytes
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* A newline line-splitter for streamed child-process output (audit CC3/CC4/SIMP5).
*
* `download.ts` and `terminal.ts` each had the same hand-rolled loop: accumulate
* chunk strings, emit every complete `\n`-terminated line (stripping a trailing
* `\r`), and keep the partial remainder for the next chunk. This is that logic in
* one place, and — being pure and synchronous — it's unit-tested directly.
*
* Usage: feed decoded chunks with `push()`, and call `flush()` on stream close to
* emit any trailing partial line that never saw a newline.
*/
export interface LineBuffer {
/** Accept a decoded chunk; invokes the sink for each newly-completed line. */
push: (chunk: string) => void
/** Emit any buffered remainder (a final line with no trailing newline). */
flush: () => void
}
export function createLineBuffer(onLine: (line: string) => void): LineBuffer {
let buf = ''
return {
push(chunk: string): void {
buf += chunk
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
onLine(buf.slice(0, nl).replace(/\r$/, ''))
buf = buf.slice(nl + 1)
}
},
flush(): void {
if (buf) {
onLine(buf)
buf = ''
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import { app } from 'electron'
import { join } from 'path'
import { appendFileSync, mkdirSync, renameSync, statSync } from 'fs'
import { LOG_MAX_BYTES } from './constants'
/**
* One small leveled, file-backed logger for the whole app (audit CC8). Before this,
* diagnostics were effectively invisible in a packaged build: main-process catches
* did a bare `console.error` (unreachable — the app menu/DevTools are suppressed in
* production, M31) and the renderer's `logError` (M29) wrote to a console nobody could
* open. Every catch now routes here and lands in `<userData>/logs/aerofetch.log`, so a
* field-reported failure is actually diagnosable.
*
* Scope: this is the diagnostic log. `errorlog.ts` remains separate — it's user-facing
* *download* failure data shown in Settings → Diagnostics, not app logging. The
* user-facing toast half of CC8 ties to the global status surface (UI25/UX9).
*
* All `app`/fs access is lazy and guarded so importing this module never touches
* `app` before it's ready — and so it's a safe no-op under unit tests, where
* `electron`'s `app` is undefined.
*/
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 }
// Write everything at or above this level to the file. `debug` is dev-only noise, so
// it never hits the file (but still prints to the dev console).
const FILE_THRESHOLD = LEVELS.info
/** Format one log line: `2026-07-01T08:30:00.000Z [ERROR] message`. Pure, for tests. */
export function formatLine(level: LogLevel, message: string, now: Date = new Date()): string {
return `${now.toISOString()} [${level.toUpperCase()}] ${message}`
}
/** Combine an operation label with an optional error/detail into one message. Pure. */
export function composeMessage(label: string, detail?: unknown): string {
if (detail === undefined) return label
const text = detail instanceof Error ? detail.message : String(detail)
return `${label}: ${text}`
}
// `undefined` = not yet resolved, `null` = resolution failed (no app/userData, e.g.
// under unit tests) so file logging is disabled; a string is the resolved path.
let cachedFile: string | null | undefined
function logFile(): string | null {
if (cachedFile !== undefined) return cachedFile
try {
const dir = join(app.getPath('userData'), 'logs')
mkdirSync(dir, { recursive: true })
cachedFile = join(dir, 'aerofetch.log')
} catch {
// No app / userData available (unit test, or app not ready) — disable the file sink.
cachedFile = null
}
return cachedFile
}
/** Dev mode, resolved lazily and guarded (app is undefined in the test env). */
function isDev(): boolean {
try {
return !app.isPackaged
} catch {
return false
}
}
// Rotate to `<file>.1` (single generation) once the live file passes the cap, so the
// log can't grow without bound. Best-effort: any fs error here is swallowed — logging
// must never throw into a catch handler.
function rotateIfNeeded(file: string): void {
try {
if (statSync(file).size > LOG_MAX_BYTES) renameSync(file, `${file}.1`)
} catch {
/* file missing (first write) or rename race — ignore */
}
}
function writeToFile(line: string): void {
const file = logFile()
if (!file) return
rotateIfNeeded(file)
try {
appendFileSync(file, line + '\n')
} catch {
/* disk full / read-only profile — nothing more we can safely do */
}
}
function emit(level: LogLevel, label: string, detail?: unknown): void {
const message = composeMessage(label, detail)
// Console in dev keeps the familiar `--inspect` workflow; the file is the sink that
// survives into a packaged build.
if (isDev()) {
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
fn(`[AeroFetch] ${message}`)
}
if (LEVELS[level] <= FILE_THRESHOLD) writeToFile(formatLine(level, message))
}
export const logger = {
error: (label: string, detail?: unknown): void => emit('error', label, detail),
warn: (label: string, detail?: unknown): void => emit('warn', label, detail),
info: (label: string, detail?: unknown): void => emit('info', label, detail),
debug: (label: string, detail?: unknown): void => emit('debug', label, detail)
}
+40
View File
@@ -0,0 +1,40 @@
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
/**
* Filesystem path helpers for the main process. Kept separate from settings.ts
* (the electron-store persistence layer) so path derivation and the settings
* store don't share a module — they have different concerns and dependencies (L69).
* All resolve app paths, so callers must run post-`app.ready`.
*/
/** Fixed path for the --download-archive file; not user-configurable. */
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 */
}
}
}
+148
View File
@@ -0,0 +1,148 @@
import { BrowserWindow, type WebContents } from 'electron'
import { LOGIN_PARTITION } from './cookies'
/** A public YouTube video used as the extraction target (stable, always accessible). */
const YT_VIDEO = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
/**
* Restrict a PO-token window to *.youtube.com and accounts.google.com (for
* sign-in). Mirrors hardenLoginWebContents in cookies.ts.
*/
function isAllowedPoTokenUrl(target: string): boolean {
try {
const { protocol, hostname } = new URL(target)
if (protocol !== 'http:' && protocol !== 'https:') return false
return (
hostname === 'youtube.com' ||
hostname.endsWith('.youtube.com') ||
hostname === 'accounts.google.com' ||
hostname.endsWith('.accounts.google.com')
)
} catch {
return false
}
}
function hardenPoTokenWebContents(wc: WebContents): void {
wc.setWindowOpenHandler((details) => {
if (!isAllowedPoTokenUrl(details.url)) return { action: 'deny' }
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
}
}
})
wc.on('will-navigate', (e, navUrl) => {
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
})
wc.on('will-redirect', (e, navUrl) => {
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
})
wc.on('did-create-window', (child) => hardenPoTokenWebContents(child.webContents))
}
/**
* Async IIFE injected into the YouTube page after load. Polls
* ytInitialPlayerResponse.serviceIntegrityDimensions.poToken (the field
* yt-dlp's youtube extractor reads) for up to 8 s, returning the token
* string or null if it never appears. The poll loop is needed because the
* player JS initialises asynchronously after the DOM is ready.
*/
const EXTRACT_SCRIPT = `
(async function() {
for (let i = 0; i < 16; i++) {
try {
const pot = window.ytInitialPlayerResponse
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions.poToken
if (typeof pot === 'string' && pot.length > 0) return pot
} catch (_) {}
await new Promise(r => setTimeout(r, 500))
}
return null
})()
`
let mintWindow: BrowserWindow | null = null
/**
* Open a visible BrowserWindow on a YouTube video page and extract the
* Proof-of-Origin token from the page runtime. The window uses the shared
* login session so a signed-in user's credentials are available.
*
* Resolves with the token string on success, or null if the window was closed
* by the user before extraction completed or if the field was not found.
*/
export function openPoTokenWindow(): Promise<string | null> {
// If a minting window is already open, bring it to the front rather than
// opening a second one.
if (mintWindow && !mintWindow.isDestroyed()) {
mintWindow.focus()
// Return a promise that resolves when the existing window closes.
return new Promise((resolve) => {
mintWindow!.once('closed', () => resolve(null))
})
}
return new Promise((resolve) => {
let resolved = false
const finish = (token: string | null): void => {
if (resolved) return
resolved = true
resolve(token)
}
let win: BrowserWindow
try {
win = new BrowserWindow({
width: 960,
height: 640,
title: 'AeroFetch — Fetch YouTube token',
autoHideMenuBar: true,
webPreferences: {
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
} catch {
finish(null)
return
}
mintWindow = win
hardenPoTokenWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _perm, cb) => cb(false))
win.webContents.once('did-finish-load', () => {
win.webContents
.executeJavaScript(EXTRACT_SCRIPT, true)
.then((result: unknown) => {
const token = typeof result === 'string' && result.length > 0 ? result : null
finish(token)
win.close()
})
.catch(() => {
finish(null)
win.close()
})
})
win.on('closed', () => {
mintWindow = null
finish(null)
})
win.loadURL(YT_VIDEO).catch(() => {
/* navigation errors surface as Chromium's own error page */
})
})
}
+34 -42
View File
@@ -1,7 +1,8 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries' import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { fmtBytes } from './download' import { execFileAsync } from './lib/exec'
import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
import { fmtBytes } from '@shared/format'
import { cleanError } from './log' import { cleanError } from './log'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore' import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
@@ -69,7 +70,10 @@ function buildVideoFormats(raw: RawFormat[]): FormatOption[] {
const options = [...bestByHeight.values()] const options = [...bestByHeight.values()]
.sort((a, b) => (b.height ?? 0) - (a.height ?? 0)) .sort((a, b) => (b.height ?? 0) - (a.height ?? 0))
.map(toOption) .map(toOption)
options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', ext: 'mp4', hasAudio: true }) // No `ext`: the final container for the auto-best pick depends on the user's
// videoContainer setting (mp4/mkv/webm), unknown here -- claiming 'mp4' would
// mislabel mkv/webm outputs (L62). hasAudio is always true (best merges audio).
options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', hasAudio: true })
return options return options
} }
@@ -110,52 +114,40 @@ function buildPlaylist(data: RawInfo): PlaylistInfo {
* its format list) or, when the URL is a playlist, the flat list of its entries. * its format list) or, when the URL is a playlist, the flat list of its entries.
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases. * `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
*/ */
export function probeMedia(url: string): Promise<ProbeResult> { export async function probeMedia(url: string): Promise<ProbeResult> {
const ytdlp = getYtdlpPath() const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) { if (!existsSync(ytdlp)) {
return Promise.resolve({ return { ok: false, error: YTDLP_MISSING_MSG }
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
})
} }
let target: string let target: string
try { try {
target = assertHttpUrl(url) // normalised form (audit F5) target = assertHttpUrl(url) // normalised form (audit F5)
} catch (e) { } catch (e) {
return Promise.resolve({ ok: false, error: (e as Error).message }) return { ok: false, error: (e as Error).message }
} }
return new Promise((resolve) => { // `--` terminates option parsing so the URL can never be read as a flag.
execFile( const r = await execFileAsync(ytdlp, ['-J', '--flat-playlist', '--no-warnings', '--', target], {
ytdlp, maxBuffer: PROBE_MAX_BUFFER,
// `--` terminates option parsing so the URL can never be read as a flag. timeout: PROBE_TIMEOUT_MS
['-J', '--flat-playlist', '--no-warnings', '--', target],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
(err, stdout, stderr) => {
if (err) {
// execFile sets `killed` when it terminated the process on timeout.
const msg = (err as { killed?: boolean }).killed
? 'Timed out fetching video info. Check the link or your connection.'
: cleanError(stderr) || err.message
resolve({ ok: false, error: msg })
return
}
try {
const data = JSON.parse(stdout) as RawInfo
if (data._type === 'playlist' || Array.isArray(data.entries)) {
const playlist = buildPlaylist(data)
if (playlist.count === 0) {
resolve({ ok: false, error: 'This playlist has no downloadable entries.' })
return
}
resolve({ ok: true, kind: 'playlist', playlist })
} else {
resolve({ ok: true, kind: 'video', info: buildInfo(data) })
}
} catch {
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
}
}
)
}) })
if (!r.ok) {
const msg = r.timedOut
? 'Timed out fetching video info. Check the link or your connection.'
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
try {
const data = JSON.parse(r.stdout) as RawInfo
if (data._type === 'playlist' || Array.isArray(data.entries)) {
const playlist = buildPlaylist(data)
if (playlist.count === 0) {
return { ok: false, error: 'This playlist has no downloadable entries.' }
}
return { ok: true, kind: 'playlist', playlist }
}
return { ok: true, kind: 'video', info: buildInfo(data) }
} catch {
return { ok: false, error: 'Could not parse video info from yt-dlp.' }
}
} }
+46
View File
@@ -0,0 +1,46 @@
import { createJsonStore } from './jsonStore'
import { QUEUE_MAX } from './constants'
import type { PersistedQueueItem, PersistedQueueStatus } from '@shared/ipc'
/**
* Persistence for the download queue (M4). The queue lived only in renderer memory,
* so `saved` / scheduled and other pending downloads were silently lost on quit.
* The renderer now mirrors its durable items here (via `queueSave`) and rehydrates
* them on launch (via `queueList`), so a parked/scheduled download returns.
*
* Reuses the shared cached-atomic `createJsonStore` (R1/R2/R3), so writes are atomic
* and coalesced — the renderer can call `queueSave` freely on queue changes.
*/
const PERSISTED_STATUSES: readonly PersistedQueueStatus[] = [
'queued',
'downloading',
'paused',
'saved',
'error'
]
/** Defensive row validator (a hand-edited or partially-written file can't crash a read). */
function isValidQueueItem(o: unknown): o is PersistedQueueItem {
if (typeof o !== 'object' || o === null) return false
const it = o as Record<string, unknown>
return (
typeof it.id === 'string' &&
typeof it.url === 'string' &&
typeof it.title === 'string' &&
(it.kind === 'video' || it.kind === 'audio') &&
typeof it.quality === 'string' &&
typeof it.status === 'string' &&
(PERSISTED_STATUSES as readonly string[]).includes(it.status)
)
}
const store = createJsonStore('queue.json', isValidQueueItem, QUEUE_MAX)
export function listQueue(): PersistedQueueItem[] {
return store.read()
}
export function saveQueue(items: PersistedQueueItem[]): void {
store.write(items)
}
+27 -6
View File
@@ -13,11 +13,29 @@ import { extname, isAbsolute } from 'path'
*/ */
const OPENABLE_EXTENSIONS = new Set([ const OPENABLE_EXTENSIONS = new Set([
// video // video
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv', '.mp4',
'.mkv',
'.webm',
'.mov',
'.avi',
'.flv',
'.ts',
'.m4v',
'.3gp',
'.ogv',
// audio // audio
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma', '.mp3',
'.m4a',
'.opus',
'.ogg',
'.oga',
'.aac',
'.flac',
'.wav',
'.wma',
// subtitle sidecars (plain text — safe to open) // subtitle sidecars (plain text — safe to open)
'.vtt', '.srt' '.vtt',
'.srt'
]) ])
/** Open a downloaded media file with its default app. Returns '' on success, /** Open a downloaded media file with its default app. Returns '' on success,
@@ -35,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise<string> {
return shell.openPath(p) return shell.openPath(p)
} }
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */ /** Reveal a path in the OS file manager. Returns '' on success, or an error
export function safeShowInFolder(p: unknown): void { * string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return export function safeShowInFolder(p: unknown): string {
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
if (!existsSync(p)) return 'File not found — it may have been moved or deleted.'
shell.showItemInFolder(p) shell.showItemInFolder(p)
return ''
} }
+9 -4
View File
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
return new Promise((resolve) => { return new Promise((resolve) => {
// Resolve schtasks from System32 by absolute path, not the bare name, so a // 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) // planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => { execFile(
const code = err ? ((err as { code?: number }).code ?? 1) : 0 getSystem32Path('schtasks.exe'),
resolve({ code, stdout: String(stdout), stderr: String(stderr) }) args,
}) { windowsHide: true },
(err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
}
)
}) })
} }
+89 -87
View File
@@ -1,8 +1,7 @@
import { app, safeStorage } from 'electron' import { app, safeStorage } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store' import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation' import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import { logger } from './logger'
import { import {
AUDIO_FORMATS, AUDIO_FORMATS,
VIDEO_CONTAINERS, VIDEO_CONTAINERS,
@@ -10,50 +9,24 @@ import {
SPONSORBLOCK_CATEGORIES, SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS, COOKIE_BROWSERS,
ACCENT_COLORS, ACCENT_COLORS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
DEFAULT_DOWNLOAD_OPTIONS, DEFAULT_DOWNLOAD_OPTIONS,
DEFAULT_SETTINGS,
isYtdlpUpdateChannel, isYtdlpUpdateChannel,
type Settings, type Settings,
type DownloadOptions, type DownloadOptions,
type SponsorBlockCategory type SponsorBlockCategory,
type AudioFormat,
type VideoContainer,
type VideoCodecPref
} from '@shared/ipc' } from '@shared/ipc'
const DEFAULTS: Settings = { // The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared
// Both blank by default → downloads land in Documents\Video / Documents\Audio // contract — single-sourced so the renderer FALLBACK and the preview mock can't
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override. // drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives
videoDir: '', // alongside DEFAULT_SETTINGS in shared/ipc.ts.
audioDir: '', const DEFAULTS: Settings = DEFAULT_SETTINGS
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
downloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// nightly channel (fastest to follow YouTube changes that cause 403s).
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
updateToken: ''
}
/** /**
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at * Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
@@ -69,56 +42,29 @@ export function applyLaunchAtStartup(enabled: boolean): void {
} }
} }
/** Fixed path for the --download-archive file; not user-configurable. */
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 // Coerce an untrusted partial into a valid DownloadOptions, falling back to the
// defaults for any missing/invalid field. Used both to migrate older settings // defaults for any missing/invalid field. Used both to migrate older settings
// files (which predate downloadOptions) and to validate renderer writes. // files (which predate downloadOptions) and to validate renderer writes.
function sanitizeOptions(input: unknown): DownloadOptions { function sanitizeOptions(input: unknown): DownloadOptions {
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions> const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
const d = DEFAULT_DOWNLOAD_OPTIONS const d = DEFAULT_DOWNLOAD_OPTIONS
const bool = (v: unknown, fallback: boolean): boolean => const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
typeof v === 'boolean' ? v : fallback
const cats = Array.isArray(o.sponsorBlockCategories) const cats = Array.isArray(o.sponsorBlockCategories)
? (o.sponsorBlockCategories.filter((c) => ? (o.sponsorBlockCategories.filter((c) =>
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c) (SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
) as SponsorBlockCategory[]) ) as SponsorBlockCategory[])
: d.sponsorBlockCategories : d.sponsorBlockCategories
return { return {
audioFormat: AUDIO_FORMATS.includes(o.audioFormat as never) ? o.audioFormat! : d.audioFormat, audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string)
videoContainer: VIDEO_CONTAINERS.includes(o.videoContainer as never) ? (o.audioFormat as AudioFormat)
? o.videoContainer! : d.audioFormat,
videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string)
? (o.videoContainer as VideoContainer)
: d.videoContainer, : d.videoContainer,
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never) preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes(
? o.preferredVideoCodec! o.preferredVideoCodec as string
)
? (o.preferredVideoCodec as VideoCodecPref)
: d.preferredVideoCodec, : d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort, formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles), embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
@@ -133,6 +79,9 @@ function sanitizeOptions(input: unknown): DownloadOptions {
embedChapters: bool(o.embedChapters, d.embedChapters), embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters), splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata), embedMetadata: bool(o.embedMetadata, d.embedMetadata),
metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined,
metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined,
metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined,
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail), cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson), writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
@@ -149,13 +98,19 @@ function getStore(): Store<Settings> {
return store return store
} }
// Decrypted-settings cache — avoids repeated DPAPI calls on hot paths such as
// buildCommand, the maxConcurrent check, completion notify, and the system-theme
// bridge (PERF1). Invalidated by every setSettings write and by the one-time
// migrateSecretsAtRest so callers always see the current values.
let cachedSettings: Settings | null = null
// --- Credential encryption at rest ------------------------------------------ // --- Credential encryption at rest ------------------------------------------
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API // proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows) // tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
// so a leaked settings.json doesn't expose them. They're still decrypted before // so a leaked settings.json doesn't expose them. They're decrypted before
// reaching the renderer and exported in clear by backup — this guards the file at // reaching the renderer; backup export strips them entirely (see backup.ts, M22).
// rest only. // Exported so backup.ts shares this one list rather than keeping a parallel copy.
const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a // Marks a value produced by encryptSecret, so a read can tell ciphertext from a
// legacy plaintext value (written before encryption existed, or while safeStorage // legacy plaintext value (written before encryption existed, or while safeStorage
@@ -172,6 +127,11 @@ function encryptSecret(plain: string): string {
} catch { } catch {
/* fall through — store plaintext, as it was before encryption existed */ /* fall through — store plaintext, as it was before encryption existed */
} }
// R7: OS encryption (DPAPI/keychain) is unavailable, so this credential is about
// to be written in cleartext. Warn rather than fall back silently — a leaked
// settings.json would then expose the proxy password / API token. (DPAPI is
// effectively always present on Windows, so this should never fire in practice.)
logger.warn('OS secret encryption unavailable — storing credential as plaintext')
return plain return plain
} }
@@ -213,9 +173,11 @@ export function migrateSecretsAtRest(): void {
const raw = s.get(key) ?? '' const raw = s.get(key) ?? ''
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw)) if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
} }
cachedSettings = null
} }
export function getSettings(): Settings { export function getSettings(): Settings {
if (cachedSettings) return cachedSettings
const s = getStore() const s = getStore()
// getSettings() is on hot paths (buildCommand, notification checks, the system- // getSettings() is on hot paths (buildCommand, notification checks, the system-
// theme bridge, several IPC handlers). electron-store writes to disk on every // theme bridge, several IPC handlers). electron-store writes to disk on every
@@ -236,9 +198,15 @@ export function getSettings(): Settings {
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) { if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
s.set('accentColor', DEFAULTS.accentColor) s.set('accentColor', DEFAULTS.accentColor)
} }
// Migrate the legacy audio-quality label 'Best (MP3)' to the format-agnostic
// 'Best' so the dropdown matches and the label no longer names a format (M18).
if (cur.defaultAudioQuality === 'Best (MP3)') {
s.set('defaultAudioQuality', 'Best')
}
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're // Hand callers (and, via IPC, the renderer) plaintext credentials — they're
// only encrypted on disk (see withDecryptedSecrets / encryptSecret). // only encrypted on disk (see withDecryptedSecrets / encryptSecret).
return withDecryptedSecrets(s.store) cachedSettings = withDecryptedSecrets(s.store)
return cachedSettings
} }
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */ /** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
@@ -264,6 +232,20 @@ function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
// background (theme), so an out-of-range or malformed value shouldn't get stored. // background (theme), so an out-of-range or malformed value shouldn't get stored.
export function setSettings(partial: Partial<Settings>): Settings { export function setSettings(partial: Partial<Settings>): Settings {
const s = getStore() const s = getStore()
try {
applySettings(s, partial)
} catch (e) {
// R5: a write failure (disk full, read-only profile) must not crash the IPC
// handler or surface as an unhandled rejection. Log it; getSettings() below
// returns the store's ACTUAL persisted state, so the renderer's reconciliation
// (M34) reflects what truly saved instead of the optimistic value.
logger.error('settings write failed', e)
}
cachedSettings = null
return getSettings()
}
function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
for (const key of Object.keys(partial) as (keyof Settings)[]) { for (const key of Object.keys(partial) as (keyof Settings)[]) {
const value = partial[key] const value = partial[key]
if (value === undefined) continue if (value === undefined) continue
@@ -294,6 +276,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'autoDownloadNew': case 'autoDownloadNew':
case 'hasCompletedOnboarding': case 'hasCompletedOnboarding':
case 'minimizeToTray': case 'minimizeToTray':
case 'sidebarCollapsed':
if (typeof value === 'boolean') s.set(key, value) if (typeof value === 'boolean') s.set(key, value)
break break
case 'launchAtStartup': case 'launchAtStartup':
@@ -343,23 +326,42 @@ export function setSettings(partial: Partial<Settings>): Settings {
} }
break break
case 'defaultVideoQuality': case 'defaultVideoQuality':
if (
typeof value === 'string' &&
(VIDEO_QUALITY_OPTIONS as readonly string[]).includes(value)
) {
s.set('defaultVideoQuality', value)
}
break
case 'defaultAudioQuality': case 'defaultAudioQuality':
if (
typeof value === 'string' &&
(AUDIO_QUALITY_OPTIONS as readonly string[]).includes(value)
) {
s.set('defaultAudioQuality', value)
}
break
case 'rateLimit': case 'rateLimit':
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
if (typeof value === 'string' && /^(\d+\.?\d*[KMGkmg]?B?)?$/.test(value.trim())) {
s.set('rateLimit', value.trim())
}
break
case 'youtubePlayerClient': case 'youtubePlayerClient':
if (typeof value === 'string') s.set(key, value) // Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
if (typeof value === 'string') s.set('youtubePlayerClient', value.trim())
break break
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy // Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
// may embed user:pass@host; youtubePoToken is an access token. exportBackup // may embed user:pass@host; youtubePoToken is an access token. Both are
// still writes them in clear (via the decrypted getSettings), as documented. // stripped from backup exports (see SECRET_KEYS / backup.ts).
case 'proxy': case 'proxy':
case 'youtubePoToken': case 'youtubePoToken':
if (typeof value === 'string') s.set(key, encryptSecret(value)) if (typeof value === 'string') s.set(key, encryptSecret(value))
break break
case 'updateToken': case 'updateToken':
// A Gitea token has no spaces; trim before encrypting (like proxy creds). // An access token has no spaces; trim before encrypting (like proxy creds).
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim())) if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
break break
} }
} }
return getSettings()
} }
+29 -52
View File
@@ -2,58 +2,39 @@
* Persistence for the media-manager index (Pinchflat-style; see * Persistence for the media-manager index (Pinchflat-style; see
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the * 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 * 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 * better-sqlite3 -- revisit if a user indexes many large channels, see the
* Phase H risk note in the roadmap): * Phase H risk note in the roadmap):
* *
* sources.json one Source record per added channel/playlist * sources.json -- one Source record per added channel/playlist
* media-items.json every MediaItem across all sources (queried by sourceId) * 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 * 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). * 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 type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation' import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore' import { mergeItemsPreservingState } from './indexerCore'
import { createJsonStore } from './jsonStore'
// A generous global cap (MEDIA_ITEMS_MAX, see constants.ts) 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).
import { MEDIA_ITEMS_MAX as MAX_ITEMS } from './constants'
// A generous global cap so a runaway index can't grow the file unbounded; large // Two cached, atomically-written stores (R1R3 via the shared jsonStore). The
// enough for several big channels. When exceeded, the most-recently-written // media-items store is the hot path: setMediaItemDownloaded used to re-read and
// source's items are kept (they're placed first by replaceMediaItems). // rewrite the whole (≤MAX_ITEMS) file on every completion; now reads hit the
const MAX_ITEMS = 20000 // cache and writes are batched. Sources are few, so that store is uncapped.
const sourcesStore = createJsonStore('sources.json', isValidSource, Infinity)
function sourcesFile(): string { // Compact (not pretty) -- this store can hold up to MAX_ITEMS rows; indentation
return join(app.getPath('userData'), 'sources.json') // would needlessly inflate its size and write time (R8).
} const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS, false)
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 ---------------------------------------------------------------- // --- Sources ----------------------------------------------------------------
export function listSources(): Source[] { export function listSources(): Source[] {
return readJsonArray(sourcesFile(), isValidSource) return sourcesStore.read()
} }
export function getSource(id: string): Source | undefined { export function getSource(id: string): Source | undefined {
@@ -62,31 +43,25 @@ export function getSource(id: string): Source | undefined {
/** Insert or replace a source by id (a re-index updates the existing record). */ /** Insert or replace a source by id (a re-index updates the existing record). */
export function upsertSource(source: Source): Source[] { export function upsertSource(source: Source): Source[] {
const sources = [source, ...listSources().filter((s) => s.id !== source.id)] return sourcesStore.write([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. */ /** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
export function setSourceWatched(id: string, watched: boolean): Source[] { export function setSourceWatched(id: string, watched: boolean): Source[] {
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s)) return sourcesStore.write(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. */ /** Remove a source and all of its media items. Returns the remaining sources. */
export function removeSource(id: string): Source[] { export function removeSource(id: string): Source[] {
const sources = listSources().filter((s) => s.id !== id) const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
writeJson(sourcesFile(), sources) itemsStore.write(listAllItems().filter((m) => m.sourceId !== id))
const items = listAllItems().filter((m) => m.sourceId !== id)
writeJson(itemsFile(), items)
return sources return sources
} }
// --- Media items ------------------------------------------------------------ // --- Media items ------------------------------------------------------------
function listAllItems(): MediaItem[] { function listAllItems(): MediaItem[] {
return readJsonArray(itemsFile(), isValidMediaItem) return itemsStore.read()
} }
export function listMediaItems(sourceId: string): MediaItem[] { export function listMediaItems(sourceId: string): MediaItem[] {
@@ -95,12 +70,14 @@ export function listMediaItems(sourceId: string): MediaItem[] {
/** /**
* Replace all media items for one source with a fresh set (the result of a * 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 * (re)index). Other sources' items are preserved in full; only the source being
* so they survive the MAX_ITEMS cap. * written here absorbs the MAX_ITEMS cap, so re-indexing one (large) source can
* never silently evict another source's items past the global limit (M23).
*/ */
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void { export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
const others = listAllItems().filter((m) => m.sourceId !== sourceId) const others = listAllItems().filter((m) => m.sourceId !== sourceId)
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS)) const budget = Math.max(0, MAX_ITEMS - others.length)
itemsStore.write([...items.slice(0, budget), ...others])
} }
/** /**
@@ -130,6 +107,6 @@ export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem
const updated = all.map((m) => const updated = all.map((m) =>
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
) )
writeJson(itemsFile(), updated) itemsStore.write(updated)
return updated.filter((m) => m.sourceId === target.sourceId) return updated.filter((m) => m.sourceId === target.sourceId)
} }
+2 -1
View File
@@ -8,6 +8,7 @@
import { listSources, listMediaItems } from './sources' import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer' import { indexSource } from './indexer'
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore' import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
import { FEED_FETCH_TIMEOUT_MS } from './constants'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc' import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */ /** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
@@ -16,7 +17,7 @@ async function fetchFeedIds(feedUrl: string): Promise<string[]> {
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is // 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. // 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.') if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) }) const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
if (!res.ok) throw new Error(`feed responded ${res.status}`) if (!res.ok) throw new Error(`feed responded ${res.status}`)
return parseRssVideoIds(await res.text()) return parseRssVideoIds(await res.text())
} }
+12 -36
View File
@@ -1,38 +1,20 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { CommandTemplate } from '@shared/ipc' import type { CommandTemplate } from '@shared/ipc'
import { isTemplateLike } from './validation' import { isTemplateLike } from './validation'
import { createJsonStore } from './jsonStore'
import { TEMPLATES_MAX } from './constants'
// Plain JSON in userData, same shape as history.ts. // Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
const MAX_TEMPLATES = 100 // backup / caching come from the shared jsonStore (R1R3).
function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
// A persisted template entry must at least be an object carrying an id (see // A persisted template entry must at least be an object carrying an id (see
// isTemplateLike in validation.ts); everything else is coerced by sanitize(). // isTemplateLike in validation.ts); everything else is coerced by sanitize().
// Drop anything that isn't, so a hand-edited templates.json can't inject // Drop anything that isn't, so a hand-edited templates.json can't inject
// malformed entries. (audit S5) // malformed entries. (audit S5)
export function listTemplates(): CommandTemplate[] { const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX)
try {
if (!existsSync(templatesFile())) return []
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
// Validate shape, then normalise each surviving entry through sanitize() so
// name/args are always well-formed strings regardless of what was on disk.
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
} catch {
return []
}
}
function save(templates: CommandTemplate[]): void { export function listTemplates(): CommandTemplate[] {
try { // Normalise each surviving entry through sanitize() so name/args are always
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2)) // well-formed strings regardless of what was on disk.
} catch { return store.read().map(sanitize)
/* best-effort; a read-only data dir just means no persisted templates */
}
} }
function sanitize(t: CommandTemplate): CommandTemplate { function sanitize(t: CommandTemplate): CommandTemplate {
@@ -49,20 +31,14 @@ function sanitize(t: CommandTemplate): CommandTemplate {
/** Add a new template, or update an existing one (matched by id). */ /** Add a new template, or update an existing one (matched by id). */
export function saveTemplate(template: CommandTemplate): CommandTemplate[] { export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
const clean = sanitize(template) const clean = sanitize(template)
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)] return store.write([clean, ...listTemplates().filter((t) => t.id !== clean.id)])
save(templates)
return templates
} }
export function removeTemplate(id: string): CommandTemplate[] { export function removeTemplate(id: string): CommandTemplate[] {
const templates = listTemplates().filter((t) => t.id !== id) return store.write(listTemplates().filter((t) => t.id !== id))
save(templates)
return templates
} }
/** Replace the entire template list (backup restore) rather than merge-by-id. */ /** Replace the entire template list (backup restore) rather than merge-by-id. */
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] { export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
const clean = templates.map(sanitize) return store.write(templates.map(sanitize))
save(clean)
return clean
} }
+8 -14
View File
@@ -14,6 +14,7 @@ import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings' import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp' import { ensureManagedYtdlp } from './ytdlp'
import { parseExtraArgs } from './buildArgs' import { parseExtraArgs } from './buildArgs'
import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc' import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>() const active = new Map<string, ChildProcess>()
@@ -47,18 +48,12 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
active.set(id, child) active.set(id, child)
// Buffer each stream and emit on newline boundaries (flush the remainder on close). // Buffer each stream and emit on newline boundaries (flush the remainder on close).
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' } const lineBufs = {
function feed(stream: 'stdout' | 'stderr', chunk: string): void { stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })),
buffers[stream] += chunk stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' }))
let nl: number
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
buffers[stream] = buffers[stream].slice(nl + 1)
send(wc, { type: 'output', id, line, stream })
}
} }
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString())) child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString()))
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString())) child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
let settled = false let settled = false
child.on('error', (err) => { child.on('error', (err) => {
@@ -72,9 +67,8 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
settled = true settled = true
active.delete(id) active.delete(id)
// Flush any trailing partial lines that never hit a newline. // Flush any trailing partial lines that never hit a newline.
for (const stream of ['stdout', 'stderr'] as const) { lineBufs.stdout.flush()
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream }) lineBufs.stderr.flush()
}
send(wc, { type: 'done', id, code }) send(wc, { type: 'done', id, code })
}) })
+2 -1
View File
@@ -44,7 +44,8 @@ export function createTray(getWindow: () => BrowserWindow | null): void {
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico // Prefer the real app icon; fall back to the embedded glyph when no icon.ico
// ships, so minimize-to-tray always has a tray to restore from. // ships, so minimize-to-tray always has a tray to restore from.
let icon = nativeImage.createFromPath(getAppIconPath()) let icon = nativeImage.createFromPath(getAppIconPath())
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`) if (icon.isEmpty())
icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
if (icon.isEmpty()) return if (icon.isEmpty()) return
tray = new Tray(icon) tray = new Tray(icon)
+37 -13
View File
@@ -68,9 +68,10 @@ function updateDir(): string {
* an "upgrade" over the same shipped version. * an "upgrade" over the same shipped version.
*/ */
function parseVersion(v: string): number[] { function parseVersion(v: string): number[] {
return v // split() always yields at least one element; `?? ''` only satisfies the type
.replace(/^v/i, '') // checker (noUncheckedIndexedAccess) for the [0] access.
.split(/[-+]/)[0] const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
return core
.split('.') .split('.')
.map((p) => parseInt(p, 10)) .map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
@@ -105,9 +106,28 @@ interface GiteaRelease {
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the * 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 * 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). * longer run like a sha512 digest is ignored rather than sliced).
*
* When `fileName` is given (the installer asset's name), a multi-line / combined
* checksum file is matched line-by-line and the hash on the line naming THIS
* asset wins — so a `<asset>.sha256` that happens to list several files can't
* verify the installer against the wrong line's hash (B3). Falls back to the
* first standalone digest for bare single-hash files (no filename present).
*/ */
export function extractSha256(text: string): string | null { export function extractSha256(text: string, fileName?: string): string | null {
const m = text.match(/\b[a-f0-9]{64}\b/i) const hashRe = /\b[a-f0-9]{64}\b/i
if (fileName) {
const base = fileName.toLowerCase()
for (const line of text.split(/\r?\n/)) {
const m = line.match(hashRe)
if (!m) continue
// The filename is the last whitespace-delimited token on a sha256sum /
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
if (last.toLowerCase() === base) return m[0].toLowerCase()
}
}
const m = text.match(hashRe)
return m ? m[0].toLowerCase() : null return m ? m[0].toLowerCase() : null
} }
@@ -200,7 +220,9 @@ function fetchTrustedText(
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> { ): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false let settled = false
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => { const done = (
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
): void => {
if (settled) return if (settled) return
settled = true settled = true
clearTimeout(timer) clearTimeout(timer)
@@ -261,7 +283,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
const sum = await fetchTrustedText(checksumUrl) const sum = await fetchTrustedText(checksumUrl)
let expectedSha: string | null = null let expectedSha: string | null = null
if (sum.ok) { if (sum.ok) {
expectedSha = extractSha256(sum.text) expectedSha = extractSha256(sum.text, safeName)
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." } if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
} else if (sum.status === 404) { } else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) { if (REQUIRE_CHECKSUM) {
@@ -391,9 +413,6 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
}) })
} }
/** 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. */ /** Launch a freshly-downloaded installer, then quit so it can replace the app. */
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> { export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
try { try {
@@ -406,10 +425,15 @@ export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; err
return { ok: false, error: 'Refused to run an unexpected file.' } return { ok: false, error: 'Refused to run an unexpected file.' }
} }
await stat(target) // throws if the file is missing await stat(target) // throws if the file is missing
const err = await shell.openPath(target) // hand the installer to the OS // Hand the installer to the OS via ShellExecute, which (unlike a raw
// CreateProcess/spawn) honors the NSIS elevation manifest — needed if the
// build ever flips to perMachine. openPath resolves once the launch is
// initiated, so the installer process already exists when we quit.
const err = await shell.openPath(target)
if (err) return { ok: false, error: err } if (err) return { ok: false, error: err }
// Give the installer a beat to spawn, then quit so it can overwrite our files. // Quit on the next tick so this IPC response reaches the renderer first; the
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS) // launched installer is independent of our process and survives the quit.
setImmediate(() => app.quit())
return { ok: true } return { ok: true }
} catch (e) { } catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) } return { ok: false, error: e instanceof Error ? e.message : String(e) }
+82
View File
@@ -0,0 +1,82 @@
import { app, screen, type BrowserWindow, type Rectangle } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync } from 'fs'
import { logger } from './logger'
/**
* Persist and restore the main window's size / position / maximized state across
* launches (W2 / UX19) Windows apps are expected to reopen where you left them.
* Stored as one small JSON object in `<userData>/window-state.json`; a disconnected
* monitor is handled by a visibility check so the window can never restore off-screen.
*/
interface WindowState {
bounds: Rectangle
maximized: boolean
}
// Matches the createWindow() defaults; used on first run and when a saved position
// is no longer on any connected display.
const DEFAULT_SIZE = { width: 920, height: 700 }
function stateFile(): string {
return join(app.getPath('userData'), 'window-state.json')
}
function readState(): WindowState | null {
try {
const s = JSON.parse(readFileSync(stateFile(), 'utf8')) as Partial<WindowState>
if (s?.bounds && typeof s.bounds.width === 'number' && typeof s.bounds.height === 'number') {
return { bounds: s.bounds as Rectangle, maximized: !!s.maximized }
}
} catch {
/* first run or corrupt — fall back to defaults */
}
return null
}
/** True when `b` overlaps the work area of some connected display (i.e. is reachable). */
function isOnSomeDisplay(b: Rectangle): boolean {
return screen.getAllDisplays().some((d) => {
const wa = d.workArea
return (
b.x < wa.x + wa.width &&
b.x + b.width > wa.x &&
b.y < wa.y + wa.height &&
b.y + b.height > wa.y
)
})
}
/**
* The bounds + maximized flag to open the window with. Returns just a size (Electron
* then centers it) when there's no usable saved position first run, or the saved
* monitor is gone.
*/
export function initialWindowState(): {
width: number
height: number
x?: number
y?: number
maximized: boolean
} {
const saved = readState()
if (saved && isOnSomeDisplay(saved.bounds)) {
return { ...saved.bounds, maximized: saved.maximized }
}
return { ...DEFAULT_SIZE, maximized: saved?.maximized ?? false }
}
/**
* Persist the window's current *normal* bounds (un-maximized) + maximized flag.
* Best-effort; a write failure is logged, never thrown. Callers should debounce.
*/
export function saveWindowState(win: BrowserWindow): void {
if (win.isDestroyed() || win.isMinimized()) return
try {
const state: WindowState = { bounds: win.getNormalBounds(), maximized: win.isMaximized() }
writeFileSync(stateFile(), JSON.stringify(state))
} catch (e) {
logger.warn('failed to save window state', e)
}
}
+29 -45
View File
@@ -1,7 +1,9 @@
import { execFile } from 'child_process'
import { existsSync, mkdirSync, copyFileSync } from 'fs' import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { dirname } from 'path' import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath } from './binaries' import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { execFileAsync } from './lib/exec'
import { cleanError } from './log'
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
import { getSettings, setSettings } from './settings' import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy' import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import { import {
@@ -32,32 +34,24 @@ export function ensureManagedYtdlp(): void {
} }
} }
/** /** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`. export async function getYtdlpVersion(): Promise<YtdlpVersionResult> {
* Proves the main-process bundled-binary IPC path end to end.
*/
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
const ytdlpPath = getYtdlpPath() const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) { if (!existsSync(ytdlpPath)) {
return Promise.resolve({ return { ok: false, error: YTDLP_MISSING_MSG }
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
} }
return new Promise((resolve) => { const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS })
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => { if (!r.ok) {
if (err) { // Run stderr through the shared cleaner so yt-dlp's noisy output collapses to
const msg = (err as { killed?: boolean }).killed // its trailing error line, consistent with download/probe/indexer (CC6).
? 'Timed out running yt-dlp.' const msg = r.timedOut
: (stderr || err.message).trim() ? 'Timed out running yt-dlp.'
resolve({ ok: false, error: msg }) : cleanError(r.stderr) || r.error?.message || ''
return return { ok: false, error: msg }
} }
resolve({ ok: true, version: stdout.trim() }) return { ok: true, version: r.stdout.trim() }
})
})
} }
/** /**
@@ -66,13 +60,13 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
* yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and * yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and
* a per-user install; it would fail under a locked-down system install. * a per-user install; it would fail under a locked-down system install.
*/ */
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> { export async function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
// Validate against the channel allowlist BEFORE the value reaches `--update-to`. // Validate against the channel allowlist BEFORE the value reaches `--update-to`.
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an // 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 // 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) // compromised renderer over IPC) must never be forwarded. (audit F1)
if (!isYtdlpUpdateChannel(channel)) { if (!isYtdlpUpdateChannel(channel)) {
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' }) return { ok: false, error: 'Unsupported update channel.' }
} }
// Self-heal: restore the managed copy from the bundled seed before updating, so // Self-heal: restore the managed copy from the bundled seed before updating, so
@@ -81,29 +75,19 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
const ytdlpPath = getYtdlpPath() const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) { if (!existsSync(ytdlpPath)) {
return Promise.resolve({ return { ok: false, error: YTDLP_MISSING_MSG }
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
} }
return new Promise((resolve) => { const r = await execFileAsync(ytdlpPath, ['--update-to', channel], {
execFile( timeout: YTDLP_UPDATE_TIMEOUT_MS
ytdlpPath,
['--update-to', channel],
{ windowsHide: true, timeout: 60_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out updating yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return
}
resolve({ ok: true, output: stdout.trim() })
}
)
}) })
if (!r.ok) {
const msg = r.timedOut
? 'Timed out updating yt-dlp.'
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
return { ok: true, output: r.stdout.trim() }
} }
/** /**
+5
View File
@@ -21,5 +21,10 @@ export function shouldAutoCheckYtdlp(
): boolean { ): boolean {
if (!enabled) return false if (!enabled) return false
if (!lastCheck) return true if (!lastCheck) return true
// A backward system-clock correction can leave `lastCheck` in the "future"
// relative to `now`, making `now - lastCheck` negative so the daily check would
// never come due again. Treat a future timestamp as skewed/bogus and run the
// check rather than waiting out a negative interval (R9).
if (lastCheck > now) return true
return now - lastCheck >= intervalMs return now - lastCheck >= intervalMs
} }
+47 -25
View File
@@ -31,7 +31,8 @@ import {
type ScheduledSyncStatus, type ScheduledSyncStatus,
type TerminalEvent, type TerminalEvent,
type TerminalRunResult, type TerminalRunResult,
type TaskbarProgress type TaskbarProgress,
type PersistedQueueItem
} from '@shared/ipc' } from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC. // The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
@@ -57,8 +58,7 @@ const api = {
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener) return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
}, },
getYtdlpVersion: (): Promise<YtdlpVersionResult> => getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */ /** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> => getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
@@ -69,23 +69,26 @@ const api = {
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> => startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
ipcRenderer.invoke(IpcChannels.downloadStart, opts), ipcRenderer.invoke(IpcChannels.downloadStart, opts),
cancelDownload: (id: string): Promise<void> => cancelDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadCancel, id),
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
pauseDownload: (id: string): Promise<void> => pauseDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadPause, id),
ipcRenderer.invoke(IpcChannels.downloadPause, id),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder), chooseFolder: (current?: string): Promise<string | null> =>
ipcRenderer.invoke(IpcChannels.chooseFolder, current),
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path), openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
showInFolder: (path: string): Promise<void> => openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
showInFolder: (path: string): Promise<string> =>
ipcRenderer.invoke(IpcChannels.showInFolder, path), ipcRenderer.invoke(IpcChannels.showInFolder, path),
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead), readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
/** Forward a renderer-side failure to the main diagnostic log (CC8). Fire-and-forget. */
logError: (op: string, detail: string): void =>
ipcRenderer.send(IpcChannels.logWrite, op, detail),
getSettings: (): Promise<Settings> => ipcRenderer.invoke(IpcChannels.settingsGet), getSettings: (): Promise<Settings> => ipcRenderer.invoke(IpcChannels.settingsGet),
setSettings: (partial: Partial<Settings>): Promise<Settings> => setSettings: (partial: Partial<Settings>): Promise<Settings> =>
@@ -138,6 +141,13 @@ const api = {
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear), clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
/** Load the persisted download queue on launch (M4). */
listQueue: (): Promise<PersistedQueueItem[]> => ipcRenderer.invoke(IpcChannels.queueList),
/** Mirror the renderer's durable queue items to disk so they survive a quit (M4). */
saveQueue: (items: PersistedQueueItem[]): Promise<void> =>
ipcRenderer.invoke(IpcChannels.queueSave, items),
/** Opens a save dialog and writes settings + templates to the chosen JSON file. */ /** Opens a save dialog and writes settings + templates to the chosen JSON file. */
exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport), exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport),
@@ -184,22 +194,23 @@ const api = {
reindexSource: (id: string): Promise<IndexSourceResult> => reindexSource: (id: string): Promise<IndexSourceResult> =>
ipcRenderer.invoke(IpcChannels.sourceReindex, id), ipcRenderer.invoke(IpcChannels.sourceReindex, id),
removeSource: (id: string): Promise<Source[]> => removeSource: (id: string): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourceRemove, id),
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
listSourceItems: (sourceId: string): Promise<MediaItem[]> => listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId), ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
/** Persist that a media item has finished downloading (drives incremental sync). */ /** Persist that a media item has finished downloading (drives incremental sync).
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> => * Named to match the main handler `setMediaItemDownloaded` (L92). */
setMediaItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath), ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
/** Toggle whether a source is watched for new uploads. */ /** Toggle whether a source is watched for new uploads. */
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> => setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched), ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
/** Re-index all watched sources; resolves with the videos found new. */ /** Re-index all watched sources; resolves with the videos found new. Named to
syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync), * match the main function `syncWatchedSources` (L92). */
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
/** Read / write the Windows daily-sync scheduled task. */ /** Read / write the Windows daily-sync scheduled task. */
getScheduledSync: (): Promise<ScheduledSyncStatus> => getScheduledSync: (): Promise<ScheduledSyncStatus> =>
@@ -220,8 +231,7 @@ const api = {
runTerminal: (id: string, args: string): Promise<TerminalRunResult> => runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args), ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
cancelTerminal: (id: string): Promise<void> => cancelTerminal: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.terminalCancel, id),
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
/** Subscribe to live terminal output. Returns an unsubscribe function. */ /** Subscribe to live terminal output. Returns an unsubscribe function. */
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => { onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
@@ -230,9 +240,13 @@ const api = {
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener) return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
}, },
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */ /** Reflect overall queue progress on the Windows taskbar. */
setTaskbarProgress: (p: TaskbarProgress): void => setTaskbarProgress: (p: TaskbarProgress): Promise<void> =>
ipcRenderer.send(IpcChannels.taskbarProgress, p) ipcRenderer.invoke(IpcChannels.taskbarProgress, p),
/** Open a YouTube WebView to automatically extract a PO token (Phase P). */
mintPoToken: (): Promise<string | null> =>
ipcRenderer.invoke(IpcChannels.youtubePoTokenMint) as Promise<string | null>
} }
export type Api = typeof api export type Api = typeof api
@@ -248,11 +262,19 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electron) contextBridge.exposeInMainWorld('electron', electron)
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
} catch (error) { } catch (error) {
console.error(error) // M30: a broken bridge causes the renderer to silently run in preview/mock mode.
// Notify the main process so it can show a hard error dialog — without this the
// failure is completely invisible to the user.
console.error('[AeroFetch preload] contextBridge failed:', error)
try {
ipcRenderer.send(IpcChannels.preloadBridgeFailure, String(error))
} catch {
/* if IPC itself is broken there is nothing more we can do */
}
} }
} else { } else {
// @ts-ignore (defined in index.d.ts) // @ts-ignore window.electron is declared in index.d.ts
window.electron = electron window.electron = electron
// @ts-ignore (defined in index.d.ts) // @ts-ignore window.api is declared in index.d.ts
window.api = api window.api = api
} }
+172 -49
View File
@@ -1,18 +1,47 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components' import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView' import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView'
import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding' import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette' import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads' import { useDownloads } from './store/downloads'
import { summarizeQueue } from './store/queueStats' // Eagerly load the sources store for its startup side-effects (load persisted
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
// subscription (coordinator's downloadCompleted → markDownloaded). Before C2 this
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources'
import { queueSummaryOf } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme' import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
// Code-split the non-default tabs (PERF8): Downloads is the launch tab and stays
// eager, but Library/History/Terminal/Settings load their chunks on first visit so
// they (and their slice of Fluent) aren't in the initial bundle. The stores these
// views use are imported eagerly above (`useDownloads` + `./store/sources`, and
// downloads → history), so lazy-loading the *view* never delays store startup.
const LibraryView = lazy(() =>
import('./components/LibraryView').then((m) => ({ default: m.LibraryView }))
)
const HistoryView = lazy(() =>
import('./components/HistoryView').then((m) => ({ default: m.HistoryView }))
)
const TerminalView = lazy(() =>
import('./components/TerminalView').then((m) => ({ default: m.TerminalView }))
)
const SettingsView = lazy(() =>
import('./components/SettingsView').then((m) => ({ default: m.SettingsView }))
)
// How often to check whether a scheduled ('saved' + due) download should promote
// to the queue. 15s is plenty for the minute-granularity times the picker offers.
const SCHEDULE_TICK_MS = 15_000
const useStyles = makeStyles({ const useStyles = makeStyles({
provider: { provider: {
@@ -27,7 +56,8 @@ const useStyles = makeStyles({
flexGrow: 1, flexGrow: 1,
minWidth: 0, minWidth: 0,
overflowY: 'auto', overflowY: 'auto',
padding: '24px 28px' // Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else.
padding: SPACE.page
} }
}) })
@@ -36,16 +66,30 @@ function App(): React.JSX.Element {
const theme = useSettings((s) => s.theme) const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor) const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update) const updateSettings = useSettings((s) => s.update)
const showTerminal = useSettings((s) => s.customCommandEnabled)
const isDark = useResolvedDark() const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads') const tab = useNav((s) => s.tab)
const setTab = useNav((s) => s.setTab)
// Active-download count for the sidebar Downloads badge (UX9/UI25), so a run in
// progress is visible from any tab. A number selector only re-renders when the
// count actually changes -- not on every progress tick.
const activeCount = useDownloads(
(s) => s.items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
)
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
// Track the element that was focused before the palette opened so we can restore it on close.
const prePaletteRef = useRef<Element | null>(null)
// Ctrl/Cmd+K toggles the command palette. // Ctrl+K: command palette. Ctrl+,: Settings. (W8/W9)
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent): void { function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault() e.preventDefault()
prePaletteRef.current = document.activeElement
setPaletteOpen((o) => !o) setPaletteOpen((o) => !o)
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
setTab('settings')
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
@@ -54,56 +98,122 @@ function App(): React.JSX.Element {
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store // Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
// directly (not via a selector) so taskbar updates don't re-render the app. // directly (not via a selector) so taskbar updates don't re-render the app.
// Compare against the last IPC call and skip when nothing changed -- the store
// fires on every progress tick and this avoids one IPC roundtrip per tick (L25).
useEffect(() => { useEffect(() => {
let lastFraction = -1
let lastMode = ''
let lastBadge = -1
function push(items: ReturnType<typeof useDownloads.getState>['items']): void { function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items) const s = queueSummaryOf(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none' const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode }) if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return
lastFraction = s.progress
lastMode = mode
lastBadge = s.downloading
void window.api?.setTaskbarProgress?.({
fraction: s.progress,
mode,
badgeCount: s.downloading
})
} }
push(useDownloads.getState().items) push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items)) return useDownloads.subscribe((st) => push(st.items))
}, []) }, [])
const paletteActions: PaletteAction[] = [ // Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') }, // other pending items return from the previous session.
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') }, useEffect(() => {
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') }, useDownloads.getState().hydrate()
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') }, }, [])
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
{ // UX16: focus the URL field on launch so the user can paste + type immediately
id: 'new-download', // (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
label: 'New download', // register its focuser (L118), matching the command palette's "New download" focus.
hint: 'Focus URL', useEffect(() => {
run: () => { if (useNav.getState().tab !== 'downloads') return
setTab('downloads') requestAnimationFrame(() => requestAnimationFrame(() => useNav.getState().focusUrlField()))
// Wait for the download bar to mount after the tab switch, then focus. }, [])
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// The queue is persisted (M4), so a scheduled item survives a quit and fires on the
// next launch once its time has passed. A 15s tick is plenty for minute-granularity
// times. Lives here (not at the downloads store's module load) so importing the store
// in tests/preview doesn't start a stray timer, and the interval is cleared cleanly
// on unmount (L1).
useEffect(() => {
const tick = setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (
st.items.some(
(i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
)
) {
st.promoteDueScheduled()
} }
}, }, SCHEDULE_TICK_MS)
{ return () => clearInterval(tick)
id: 'toggle-theme', }, [])
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance', // Memoized so CommandPalette sees a stable array reference between renders --
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' }) // only rebuilds when the theme label needs to change (L32).
} const paletteActions = useMemo<PaletteAction[]>(
] () => [
{
id: 'go-downloads',
label: 'Go to Downloads',
hint: 'Navigate',
run: () => setTab('downloads')
},
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{
id: 'go-terminal',
label: 'Go to Terminal',
hint: 'Navigate',
run: () => setTab('terminal')
},
{
id: 'go-settings',
label: 'Go to Settings',
// Surface the real shortcut here so the palette doubles as shortcut
// discovery (UX21).
hint: 'Ctrl ,',
run: () => setTab('settings')
},
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Double-rAF waits for React's re-render + paint (so the DownloadBar has
// mounted and registered its focuser) before requesting focus (L118).
requestAnimationFrame(() =>
requestAnimationFrame(() => useNav.getState().focusUrlField())
)
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
],
[isDark, setTab, updateSettings]
)
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC. // AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
useEffect(() => { useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {}) window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
}, []) }, [])
// Sidebar collapse, persisted across launches in localStorage. const collapsed = useSettings((s) => s.sidebarCollapsed)
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
)
function toggleCollapsed(): void { function toggleCollapsed(): void {
setCollapsed((c) => { updateSettings({ sidebarCollapsed: !collapsed })
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 // Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state. // by a one-frame flash of the (default-false) onboarding state.
@@ -136,21 +246,34 @@ function App(): React.JSX.Element {
version={version} version={version}
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={toggleCollapsed} onToggleCollapsed={toggleCollapsed}
showTerminal={showTerminal}
downloadCount={activeCount}
/> />
<main className={styles.content}> <main className={styles.content}>
{tab === 'downloads' && <DownloadsView />} {tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />} <Suspense fallback={null}>
{tab === 'history' && <HistoryView />} {tab === 'library' && <LibraryView />}
{tab === 'terminal' && <TerminalView />} {tab === 'history' && <HistoryView />}
{tab === 'settings' && <SettingsView />} {tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />}
</Suspense>
</main> </main>
{paletteOpen && ( {paletteOpen && (
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} /> <CommandPalette
actions={paletteActions}
onClose={() => {
setPaletteOpen(false)
const el = prePaletteRef.current
if (el instanceof HTMLElement) requestAnimationFrame(() => el.focus())
}}
/>
)} )}
</> </>
)} )}
<LiveRegion />
<Toaster />
</div> </div>
</FluentProvider> </FluentProvider>
) )
+46
View File
@@ -1,11 +1,57 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
:focus-visible {
outline: 2px solid var(--colorBrandStroke1, #0078d4);
outline-offset: 2px;
}
/*
* Zero the UA heading margin (UI33). Titles are rendered as real headings
* (h1/h2) for Narrator heading-navigation, but they carry Fluent's typography
* classes for their visual ramp (font size/weight/line-height); those classes
* out-specify this element selector, so only the browser's default heading
* margin needs removing to keep the layout identical to the former span titles.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
html, html,
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
#root { #root {
height: 100vh; height: 100vh;
} }
/*
* One motion policy (UI26): honor the OS "reduce motion" preference everywhere.
* The app's transitions are subtle (see MOTION in components/ui/tokens.ts), but a
* user who asks for less motion gets none this near-instant override covers any
* current or future transition/animation without each component opting in.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
+33 -11
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing'
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
export interface PaletteAction { export interface PaletteAction {
id: string id: string
@@ -10,32 +12,31 @@ export interface PaletteAction {
} }
const useStyles = makeStyles({ const useStyles = makeStyles({
// A plain fixed overlay NOT a Fluent Dialog, since this app avoids Fluent's // A plain fixed overlay -- NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx). // portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: { backdrop: {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
zIndex: 1000, zIndex: Z.overlay,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'flex-start', alignItems: 'flex-start',
paddingTop: '14vh', paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)' backgroundColor: SCRIM
}, },
panel: { panel: {
width: 'min(560px, 92vw)', width: 'min(560px, 92vw)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge), ...shorthands.borderRadius(RADIUS.card),
border: `1px solid ${tokens.colorNeutralStroke2}`, border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28, boxShadow: ELEVATION.overlay,
overflow: 'hidden' overflow: 'hidden'
}, },
input: { input: {
appearance: 'none', appearance: 'none',
border: 'none', border: 'none',
outline: 'none',
padding: '14px 16px', padding: '14px 16px',
fontSize: tokens.fontSizeBase400, fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase, fontFamily: tokens.fontFamilyBase,
@@ -91,11 +92,16 @@ export function CommandPalette({
onClose: () => void onClose: () => void
}): React.JSX.Element { }): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const focus = useFocusStyles()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase())) const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
const listId = 'cmdpalette-list'
const optionId = (id: string): string => `cmdpalette-option-${id}`
const activeId = filtered[sel] ? optionId(filtered[sel].id) : undefined
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
@@ -103,6 +109,10 @@ export function CommandPalette({
useEffect(() => { useEffect(() => {
setSel(0) setSel(0)
}, [q]) }, [q])
useEffect(() => {
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [sel])
function onKeyDown(e: React.KeyboardEvent): void { function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -133,21 +143,33 @@ export function CommandPalette({
> >
<input <input
ref={inputRef} ref={inputRef}
className={styles.input} className={mergeClasses(styles.input, focus.focusRing)}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Type a command…" placeholder="Type a command…"
role="combobox"
aria-expanded={filtered.length > 0}
aria-controls={listId}
aria-activedescendant={activeId}
aria-autocomplete="list"
aria-label="Command palette search" aria-label="Command palette search"
/> />
<div className={styles.list}> <div className={styles.list} ref={listRef} id={listId} role="listbox" aria-label="Commands">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div> <div className={styles.empty}>No matching commands</div>
) : ( ) : (
filtered.map((a, i) => ( filtered.map((a, i) => (
<button // A non-focusable option: focus stays on the combobox input and the
// active row is announced via aria-activedescendant (UI27), the
// standard combobox+listbox pattern. onMouseEnter keeps the pointer
// and keyboard highlight unified on one `sel` state.
<div
key={a.id} key={a.id}
type="button" id={optionId(a.id)}
role="option"
aria-selected={i === sel}
data-sel={i === sel ? 'true' : undefined}
className={mergeClasses(styles.item, i === sel && styles.itemActive)} className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => { onClick={() => {
a.run() a.run()
@@ -157,7 +179,7 @@ export function CommandPalette({
> >
<span>{a.label}</span> <span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>} {a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button> </div>
)) ))
)} )}
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { import {
Field, Field,
Input, Input,
Switch, Switch,
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = { const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
sponsor: 'Sponsor', sponsor: 'Sponsor',
intro: 'Intro / intermission', intro: 'Intro / intermission',
outro: 'Endcards / credits', outro: 'End cards / credits',
selfpromo: 'Self-promotion', selfpromo: 'Self-promotion',
preview: 'Preview / recap', preview: 'Preview / recap',
filler: 'Filler / tangent', filler: 'Filler / tangent',
@@ -83,6 +83,8 @@ const useStyles = makeStyles({
interface Props { interface Props {
value: DownloadOptions value: DownloadOptions
onChange: (next: DownloadOptions) => void onChange: (next: DownloadOptions) => void
/** When provided, hides controls that don't apply to this media kind. */
kind?: 'video' | 'audio'
} }
/** /**
@@ -90,7 +92,7 @@ interface Props {
* Used both for the persisted defaults (Settings) and for a per-download * Used both for the persisted defaults (Settings) and for a per-download
* override (the download bar), so the two never drift apart. * override (the download bar), so the two never drift apart.
*/ */
export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Element { export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void { function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
@@ -107,35 +109,47 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className={styles.grid}> <div className={styles.grid}>
<Field label="Audio format" hint="For audio-only downloads."> {kind !== 'video' && (
<Select <Field label="Audio format" hint="For audio-only downloads.">
aria-label="Audio format" <Select
value={value.audioFormat} value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))} options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)} onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/> />
</Field> </Field>
<Field label="Video container" hint="Container for merged video."> )}
<Select {kind !== 'audio' && (
aria-label="Video container" <Field
value={value.videoContainer} label="Video container"
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))} hint="The file format used when video and audio are combined into one file."
onChange={(v) => setOpt('videoContainer', v as VideoContainer)} >
/> <Select
</Field> value={value.videoContainer}
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter."> options={VIDEO_CONTAINERS.map((c) => ({
<Select value: c,
aria-label="Preferred video codec" label: VIDEO_CONTAINER_LABELS[c]
value={value.preferredVideoCodec} }))}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))} onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)} />
/> </Field>
</Field> )}
{kind !== 'audio' && (
<Field
label="Preferred codec"
hint="A preference when several formats match -- not a strict filter."
>
<Select
value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
/>
</Field>
)}
</div> </div>
<Field <Field
label="Format sorting (advanced)" label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax." hint="Custom expression to rank available formats, e.g. res:1080,vcodec:av01,size. Leave blank to use the Preferred codec setting above. See yt-dlp docs for the full syntax."
> >
<Input <Input
value={value.formatSort} value={value.formatSort}
@@ -144,11 +158,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
/> />
</Field> </Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video."> <Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
<Switch <Switch
checked={value.embedSubtitles} checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)} onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
label={value.embedSubtitles ? 'On' : 'Off'}
/> />
</Field> </Field>
{value.embedSubtitles && ( {value.embedSubtitles && (
@@ -177,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Switch <Switch
checked={value.sponsorBlock} checked={value.sponsorBlock}
onChange={(_, d) => setOpt('sponsorBlock', d.checked)} onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
label={value.sponsorBlock ? 'On' : 'Off'}
/> />
</Field> </Field>
{value.sponsorBlock && ( {value.sponsorBlock && (
<div className={styles.subGroup}> <div className={styles.subGroup}>
<Field label="Action"> <Field label="Action">
<Select <Select
aria-label="SponsorBlock action"
value={value.sponsorBlockMode} value={value.sponsorBlockMode}
options={[ options={[
{ value: 'remove', label: 'Remove segments (cut from file)' }, { value: 'remove', label: 'Remove segments (cut from file)' },
@@ -193,7 +204,15 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)} onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)}
/> />
</Field> </Field>
<Field label="Categories"> <Field
label="Categories"
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
validationMessage={
value.sponsorBlockCategories.length === 0
? 'No categories selected -- no segments will be skipped.'
: undefined
}
>
<div className={styles.categoryGrid}> <div className={styles.categoryGrid}>
{SPONSORBLOCK_CATEGORIES.map((cat) => ( {SPONSORBLOCK_CATEGORIES.map((cat) => (
<Checkbox <Checkbox
@@ -208,11 +227,13 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</div> </div>
)} )}
<Field label="Embed chapters"> <Field
label="Embed chapters"
hint="Write chapter markers into the file. Only useful if the video has chapters."
>
<Switch <Switch
checked={value.embedChapters} checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)} onChange={(_, d) => setOpt('embedChapters', d.checked)}
label={value.embedChapters ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field <Field
@@ -222,21 +243,40 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Switch <Switch
checked={value.splitChapters} checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)} onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags."> <Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch <Switch
checked={value.embedMetadata} checked={value.embedMetadata}
onChange={(_, d) => setOpt('embedMetadata', d.checked)} onChange={(_, d) => setOpt('embedMetadata', d.checked)}
label={value.embedMetadata ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field
label="Metadata overrides"
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
>
<div className={styles.subGroup}>
<Input
placeholder="Title"
value={value.metadataTitle ?? ''}
onChange={(_, d) => setOpt('metadataTitle', d.value)}
/>
<Input
placeholder="Artist"
value={value.metadataArtist ?? ''}
onChange={(_, d) => setOpt('metadataArtist', d.value)}
/>
<Input
placeholder="Album"
value={value.metadataAlbum ?? ''}
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
/>
</div>
</Field>
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV."> <Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
<Switch <Switch
checked={value.embedThumbnail} checked={value.embedThumbnail}
onChange={(_, d) => setOpt('embedThumbnail', d.checked)} onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
label={value.embedThumbnail ? 'On' : 'Off'}
/> />
</Field> </Field>
{value.embedThumbnail && ( {value.embedThumbnail && (
@@ -254,7 +294,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Field <Field
label="Sidecar files" label="Sidecar files"
hint="Write separate metadata/poster/description files next to each download handy for Jellyfin, Plex or Kodi libraries." hint="Write separate metadata/poster/description files next to each download -- handy for Jellyfin, Plex or Kodi libraries."
> >
<div className={styles.subGroup}> <div className={styles.subGroup}>
<Checkbox <Checkbox
+55 -28
View File
@@ -1,19 +1,28 @@
import { import {
Subtitle2, Subtitle2,
Body1,
Caption1, Caption1,
Button, Button,
ProgressBar, ProgressBar,
makeStyles, makeStyles,
mergeClasses,
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons' import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads' import { useDownloads, type DownloadItem } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats' import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar' import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem' import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList' import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE, ICON, META_SEP } from './ui/tokens'
// Hoisted so the virtualizer gets stable function identities and doesn't churn its
// measurement/key cache on every render (PERF5).
const estimateRowSize = (): number => 100
const getItemKey = (item: DownloadItem): string => item.id
const renderQueueRow = (item: DownloadItem): React.JSX.Element => <QueueItem item={item} />
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -22,7 +31,8 @@ const useStyles = makeStyles({
// growing to thousands of rows. // growing to thousands of rows.
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '20px', // One section gap on every screen (UI2).
gap: SPACE.section,
height: '100%' height: '100%'
}, },
queueHeader: { queueHeader: {
@@ -56,47 +66,59 @@ const useStyles = makeStyles({
// min-height:0 lets this flex child shrink below its content height so it, // min-height:0 lets this flex child shrink below its content height so it,
// not the page, owns the scrolling. // not the page, owns the scrolling.
minHeight: 0 minHeight: 0
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '56px 16px',
color: tokens.colorNeutralForeground3,
textAlign: 'center'
} }
}) })
export function DownloadsView(): React.JSX.Element { export function DownloadsView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles()
const items = useDownloads((s) => s.items) const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished) const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll) const retryAll = useDownloads((s) => s.retryAll)
const cancelAll = useDownloads((s) => s.cancelAll)
const summary = summarizeQueue(items) const summary = queueSummaryOf(items)
const hasFinished = items.some( const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled' (i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
) )
// The header count is the live queue (work not yet finished), not the whole
// list -- completed/canceled/error rows linger until "Clear finished" (L11).
const queueCount = items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
).length
// Cancelable = the items with a live/pending run (downloading + queued); this is
// exactly what "Cancel all" acts on (UX15).
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
return ( return (
<div className={styles.root}> <div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
<DownloadBar /> <DownloadBar />
{summary.active && ( {summary.active && (
<div className={styles.summary}> <div className={styles.summary}>
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" /> <ProgressBar
className={styles.summaryBar}
value={summary.progress}
thickness="large"
aria-label="Overall download progress"
/>
<Caption1 className={styles.summaryText}> <Caption1 className={styles.summaryText}>
{summary.downloading} downloading {summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''} {summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''} {summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
{summary.etaLabel ? `~${summary.etaLabel} left` : ''} {summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
</Caption1> </Caption1>
</div> </div>
)} )}
<div className={styles.queueHeader}> <div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2> <Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}> <div className={styles.headerActions}>
{summary.failed > 0 && ( {summary.failed > 0 && (
<Button <Button
@@ -108,6 +130,11 @@ export function DownloadsView(): React.JSX.Element {
Retry all failed ({summary.failed}) Retry all failed ({summary.failed})
</Button> </Button>
)} )}
{cancelable > 0 && (
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
Cancel all ({cancelable})
</Button>
)}
{hasFinished && ( {hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}> <Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished Clear finished
@@ -117,19 +144,19 @@ export function DownloadsView(): React.JSX.Element {
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
<div className={styles.empty}> <EmptyState
<ArrowDownloadRegular fontSize={40} /> icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1> message="Nothing queued yet. Paste a URL above, then click Download."
</div> />
) : ( ) : (
<VirtualList <VirtualList
items={items} items={items}
className={styles.listScroll} className={styles.listScroll}
gap={10} gap={10}
overscan={6} overscan={6}
estimateSize={() => 100} estimateSize={estimateRowSize}
getKey={(item) => item.id} getKey={getItemKey}
renderItem={(item) => <QueueItem item={item} />} renderItem={renderQueueRow}
/> />
)} )}
</div> </div>
@@ -0,0 +1,100 @@
import React from 'react'
interface Props {
children: React.ReactNode
}
interface State {
error: Error | null
}
/**
* Top-level renderer error boundary (M16). Before this, an exception thrown in
* render by any view unmounted the whole shell to a blank white window with no
* way out. This catches it, logs it (so it reaches the dev console / future log
* sink), and shows a recover affordance.
*
* The fallback is intentionally dependency-free no Fluent components, no theme
* provider because the error may have come from inside that very tree. It paints
* its own full-window dark surface (matching the app's dark charcoal + toffee
* accent) rather than reading theme tokens, so it renders identically regardless
* of the active theme and uses only inline CSS that can't itself throw.
*/
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[AeroFetch] renderer crashed:', error, info.componentStack)
}
private handleReload = (): void => {
// A full reload re-runs the renderer from a clean state; persisted data
// (settings/history/sources) lives in main, so nothing is lost.
window.location.reload()
}
render(): React.ReactNode {
const { error } = this.state
if (!error) return this.props.children
return (
<div
role="alert"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
minHeight: '100vh',
padding: 32,
textAlign: 'center',
fontFamily: 'Segoe UI, system-ui, sans-serif',
color: '#c8c6c4',
background: '#201f1e'
}}
>
<div style={{ fontSize: 18, fontWeight: 600, color: '#f3f2f1' }}>Something went wrong</div>
<div style={{ maxWidth: 440, fontSize: 13, lineHeight: 1.5 }}>
AeroFetch hit an unexpected error and couldnt continue. Your downloads and settings are
saved reloading the window should bring you back.
</div>
<pre
style={{
maxWidth: 480,
maxHeight: 120,
overflow: 'auto',
margin: 0,
padding: '8px 12px',
fontSize: 11,
textAlign: 'left',
color: '#d29ca0',
background: '#2b2a29',
borderRadius: 6
}}
>
{error.message || String(error)}
</pre>
<button
type="button"
onClick={this.handleReload}
style={{
padding: '8px 20px',
fontSize: 14,
fontWeight: 600,
color: '#1c1611',
background: '#b5917d',
border: 'none',
borderRadius: 8,
cursor: 'pointer'
}}
>
Reload AeroFetch
</button>
</div>
)
}
}
+11 -5
View File
@@ -1,4 +1,5 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { Z } from './ui/tokens'
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a // An instant, theme-styled tooltip implemented entirely in CSS — shown via a
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child // `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
}, },
bubble: { bubble: {
position: 'absolute', position: 'absolute',
zIndex: 1000, zIndex: Z.tooltip,
visibility: 'hidden', visibility: 'hidden',
pointerEvents: 'none', pointerEvents: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
alignEnd: { right: 0 } alignEnd: { right: 0 }
}) })
interface HintProps { // `align` only shifts a top/bottom bubble left/right; it does nothing for a
// left/right placement. The union makes that explicit — passing `align` with
// `placement="left"|"right"` is a type error now, instead of being silently
// ignored (L129).
type HintProps = {
label: string label: string
placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end'
children: React.ReactNode children: React.ReactNode
} } & (
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
| { placement: 'left' | 'right'; align?: never }
)
export function Hint({ export function Hint({
label, label,
+164 -70
View File
@@ -1,12 +1,12 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { import {
Text, Text,
Caption1, Caption1,
Button, Button,
Checkbox, Checkbox,
Input, Input,
Body1,
makeStyles, makeStyles,
mergeClasses,
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
@@ -22,6 +22,7 @@ import {
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc' import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history' import { useHistory } from '../store/history'
import { formatWhen } from '../datetime'
import { useResolvedDark } from '../store/systemTheme' import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads' import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme' import { thumbColors } from '../theme'
@@ -29,12 +30,18 @@ import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint' import { Hint } from './Hint'
import { Select } from './Select' import { Select } from './Select'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, ICON, META_SEP } from './ui/tokens'
import { THUMB_SM } from '../thumbSizes'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '12px' gap: SPACE.section
}, },
header: { header: {
display: 'flex', display: 'flex',
@@ -74,8 +81,8 @@ const useStyles = makeStyles({
}, },
thumb: { thumb: {
flexShrink: 0, flexShrink: 0,
width: '72px', width: `${THUMB_SM.w}px`,
height: '44px', height: `${THUMB_SM.h}px`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -87,27 +94,15 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}, },
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
actions: { actions: {
display: 'flex', display: 'flex',
gap: '4px', gap: '4px',
flexShrink: 0 flexShrink: 0,
}, // >=40px hit target for the icon-only row actions (W16).
empty: { '& button': {
display: 'flex', minWidth: '40px',
flexDirection: 'column', minHeight: '40px'
alignItems: 'center', }
gap: '10px',
padding: '56px 16px',
textAlign: 'center'
}, },
emptyBadge: { emptyBadge: {
width: '56px', width: '56px',
@@ -115,11 +110,9 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: '50%', borderRadius: tokens.borderRadiusCircular,
fontSize: '26px' // Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
}, fontSize: `${ICON.section}px`
emptyHint: {
color: tokens.colorNeutralForeground3
}, },
noMatches: { noMatches: {
padding: '32px 16px', padding: '32px 16px',
@@ -134,19 +127,11 @@ const KIND_FILTER_OPTIONS = [
{ value: 'audio', label: 'Audio' } { value: 'audio', label: 'Audio' }
] ]
function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export function HistoryView(): React.JSX.Element { export function HistoryView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const isDark = useResolvedDark() const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light'] const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries) const entries = useHistory((s) => s.entries)
@@ -161,6 +146,8 @@ export function HistoryView(): React.JSX.Element {
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all') const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
const [selectMode, setSelectMode] = useState(false) const [selectMode, setSelectMode] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmClear, setConfirmClear] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
@@ -175,8 +162,31 @@ export function HistoryView(): React.JSX.Element {
}) })
}, [entries, query, kindFilter]) }, [entries, query, kindFilter])
// Clear selections that are no longer visible after a filter change (L86).
useEffect(() => {
if (selected.size === 0) return
const visible = new Set(filtered.map((h) => h.id))
setSelected((prev) => {
const next = new Set([...prev].filter((id) => visible.has(id)))
return next.size === prev.size ? prev : next
})
}, [filtered])
function redownload(h: HistoryEntry): void { function redownload(h: HistoryEntry): void {
addFromUrl(h.url, h.kind, h.quality, { title: h.title, channel: h.channel }) // Strip compound format labels ("720p · mp4 · 184 MB" → "720p") when there is
// no stored formatId, so videoFormat() can still match the preset (H5).
const quality = (h.quality.split(' · ')[0] ?? h.quality).trim()
addFromUrl(h.url, h.kind, quality, {
title: h.title,
channel: h.channel,
thumbnail: h.thumbnail,
// Replay the original post-processing options (L87); undefined falls back to
// the global defaults for entries recorded before this was carried.
options: h.options,
format: h.formatId
? { id: h.formatId, hasAudio: h.formatHasAudio ?? false, label: h.quality }
: undefined
})
} }
function toggleSelected(id: string, on: boolean): void { function toggleSelected(id: string, on: boolean): void {
@@ -196,6 +206,7 @@ export function HistoryView(): React.JSX.Element {
function exitSelectMode(): void { function exitSelectMode(): void {
setSelectMode(false) setSelectMode(false)
setSelected(new Set()) setSelected(new Set())
setConfirmDelete(false)
} }
function deleteSelected(): void { function deleteSelected(): void {
@@ -205,21 +216,27 @@ export function HistoryView(): React.JSX.Element {
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div className={styles.empty}> <div className={mergeClasses(styles.root, screen.width)}>
<div <ScreenHeader title="History" description="Files you've finished downloading." />
className={styles.emptyBadge} <EmptyState
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }} icon={
> <div
<HistoryRegular /> className={styles.emptyBadge}
</div> style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
<Body1>No downloads yet.</Body1> >
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1> <HistoryRegular />
</div>
}
message="No downloads yet."
hint="Finished downloads will show up here."
/>
</div> </div>
) )
} }
return ( return (
<div className={styles.root}> <div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.header}> <div className={styles.header}>
{selectMode ? ( {selectMode ? (
<> <>
@@ -228,18 +245,44 @@ export function HistoryView(): React.JSX.Element {
{allFilteredSelected ? 'Select none' : 'Select all'} {allFilteredSelected ? 'Select none' : 'Select all'}
</Button> </Button>
<div className={styles.spacer} /> <div className={styles.spacer} />
<Button {confirmDelete ? (
size="small" <>
appearance="primary" <Caption1 className={styles.count}>
icon={<DeleteRegular />} Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
onClick={deleteSelected} </Caption1>
disabled={selected.size === 0} <Button
> size="small"
Delete selected appearance="primary"
</Button> icon={<DeleteRegular />}
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}> onClick={deleteSelected}
Cancel >
</Button> Delete
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => setConfirmDelete(true)}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={exitSelectMode}
>
Cancel
</Button>
</>
)}
</> </>
) : ( ) : (
<> <>
@@ -248,6 +291,7 @@ export function HistoryView(): React.JSX.Element {
</Caption1> </Caption1>
<Input <Input
className={styles.search} className={styles.search}
input={{ 'aria-label': 'Search history' }}
size="small" size="small"
value={query} value={query}
onChange={(_, d) => setQuery(d.value)} onChange={(_, d) => setQuery(d.value)}
@@ -270,9 +314,36 @@ export function HistoryView(): React.JSX.Element {
> >
Select Select
</Button> </Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}> {confirmClear ? (
Clear history <>
</Button> <Caption1 className={styles.count}>
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
clear()
setConfirmClear(false)
}}
>
Clear all
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmClear(true)}
>
Clear history
</Button>
)}
</> </>
)} )}
</div> </div>
@@ -280,10 +351,33 @@ export function HistoryView(): React.JSX.Element {
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1> <Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : ( ) : (
<div className={styles.list}> <div
className={styles.list}
onKeyDown={(e) => {
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
// select mode if needed). Scoped to the list, so it never hijacks
// Ctrl+A in the search field, which lives in the header above.
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault()
setSelectMode(true)
setSelected(new Set(filtered.map((x) => x.id)))
}
}}
>
{filtered.map((h: HistoryEntry) => { {filtered.map((h: HistoryEntry) => {
return ( return (
<div key={h.id} className={styles.row}> <div
key={h.id}
className={mergeClasses(styles.row, focus.focusRing)}
tabIndex={0}
onKeyDown={(e) => {
// Delete removes the focused row (not when a child button is focused).
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
remove(h.id)
}
}}
>
{selectMode && ( {selectMode && (
<Checkbox <Checkbox
checked={selected.has(h.id)} checked={selected.has(h.id)}
@@ -298,11 +392,11 @@ export function HistoryView(): React.JSX.Element {
iconSize={22} iconSize={22}
/> />
<div className={styles.body}> <div className={styles.body}>
<Text className={styles.title}>{h.title}</Text> <Text className={text.title}>{h.title}</Text>
<Caption1 className={styles.meta}> <Caption1 className={text.muted}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)] {[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean) .filter(Boolean)
.join(' • ')} .join(META_SEP)}
</Caption1> </Caption1>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
+245 -217
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { import {
Subtitle2,
Body1,
Caption1, Caption1,
Text, Text,
Input, Input,
@@ -15,9 +13,8 @@ import {
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { import {
SearchRegular, AddRegular,
ArrowSyncRegular, ArrowSyncRegular,
ArrowClockwiseRegular,
DeleteRegular, DeleteRegular,
ArrowDownloadRegular, ArrowDownloadRegular,
ChevronDownRegular, ChevronDownRegular,
@@ -25,21 +22,30 @@ import {
AppsListRegular, AppsListRegular,
VideoClipMultipleRegular, VideoClipMultipleRegular,
AlertRegular, AlertRegular,
LibraryRegular, LibraryRegular
LinkRegular,
DismissRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaItem, Source } from '@shared/ipc' import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSources } from '../store/sources' import { useSources } from '../store/sources'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads' import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList' import { VirtualList } from './VirtualList'
import { Hint } from './Hint'
// True in the standalone browser preview (no Electron preload). import { ScreenHeader, useScreenStyles } from './ui/Screen'
const PREVIEW = typeof window === 'undefined' || !window.electron import { StatusChip } from './ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl'
import { EmptyState } from './ui/EmptyState'
import { LinkSuggestion } from './ui/LinkSuggestion'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_XS } from '../thumbSizes'
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */ /** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending' type ItemStatus = DownloadStatus | 'pending'
@@ -50,45 +56,28 @@ type ItemStatus = DownloadStatus | 'pending'
* window cleanly (one virtualizer over a flat array, headers included). * window cleanly (one virtualizer over a flat array, headers included).
*/ */
type LibRow = type LibRow =
| { kind: 'header'; title: string; items: MediaItem[] } { kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
| { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */ /** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100 const VIRTUALIZE_AT = 100
const STATUS_LABEL: Record<ItemStatus, string> = { // When a URL appears more than once in the queue ("Download anyway" duplicates),
pending: 'Pending', // the library row should reflect the most meaningful state: a finished copy wins,
queued: 'Queued', // then anything in flight, then terminal failures (M17).
downloading: 'Downloading', const STATUS_PRIORITY: Record<DownloadStatus, number> = {
paused: 'Paused', completed: 6,
saved: 'Saved', downloading: 5,
completed: 'Downloaded', queued: 4,
error: 'Failed', saved: 3,
canceled: 'Canceled' paused: 2,
error: 1,
canceled: 0
} }
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '18px' }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
addRow: { display: 'flex', gap: '8px' }, addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 }, addInput: { flexGrow: 1 },
suggestion: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
suggestionText: {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
toolbar: { toolbar: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -111,15 +100,6 @@ const useStyles = makeStyles({
fontSize: tokens.fontSizeBase200 fontSize: tokens.fontSizeBase200
}, },
error: { color: tokens.colorPaletteRedForeground1 }, 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' }, list: { display: 'flex', flexDirection: 'column', gap: '10px' },
card: { card: {
border: `1px solid ${tokens.colorNeutralStroke2}`, border: `1px solid ${tokens.colorNeutralStroke2}`,
@@ -132,6 +112,14 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: '12px',
padding: '12px 14px', padding: '12px 14px',
width: '100%',
border: 'none',
backgroundColor: 'transparent',
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
// matches the page foreground in dark mode instead of UA ButtonText.
color: tokens.colorNeutralForeground1,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer', cursor: 'pointer',
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover } ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
}, },
@@ -145,7 +133,8 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '18px' // Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
}, },
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }, srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }, srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
@@ -158,7 +147,6 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorBrandBackground2, backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2 color: tokens.colorBrandForeground2
}, },
srcSub: { color: tokens.colorNeutralForeground3 },
detail: { detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`, borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px', padding: '12px 14px',
@@ -172,9 +160,22 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
padding: '6px 4px', padding: '2px 4px'
},
groupToggle: {
display: 'flex',
alignItems: 'center',
gap: '8px',
flexGrow: 1,
minWidth: 0,
padding: '4px 4px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2, color: tokens.colorNeutralForeground2,
cursor: 'pointer', cursor: 'pointer',
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
textAlign: 'left',
...shorthands.borderRadius(tokens.borderRadiusMedium), ...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover } ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
}, },
@@ -187,38 +188,12 @@ const useStyles = makeStyles({
}, },
plainList: { display: 'flex', flexDirection: 'column' }, plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: { rowThumb: {
width: '60px', width: `${THUMB_XS.w}px`,
height: '34px', height: `${THUMB_XS.h}px`,
...shorthands.borderRadius(tokens.borderRadiusSmall) // One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
}, },
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }, 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. */ /** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
@@ -241,18 +216,11 @@ function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[
return groups 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 { export function LibraryView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const sources = useSources((s) => s.sources) const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource) const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId) const selectedSourceId = useSources((s) => s.selectedSourceId)
@@ -271,10 +239,32 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
// A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
// add field with it once, so the user lands here ready to add it.
const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
useEffect(() => {
if (pendingLibraryUrl === null) return
const handed = consumeLibraryUrl()
if (handed) setUrl(handed)
}, [pendingLibraryUrl, consumeLibraryUrl])
// Reset any pending Remove confirmation when the expanded source changes so a
// stale confirm can't reappear after navigating between sources.
useEffect(() => {
setConfirmRemoveId(null)
}, [selectedSourceId])
// Offer a freshly-copied link the way the Downloads tab does, but skip single // Offer a freshly-copied link the way the Downloads tab does, but skip single
// videos a library source is a channel/playlist to sync, not a one-off. // videos -- a library source is a channel/playlist to sync, not a one-off.
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u)) const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
// Per-batch video/audio choice for queueing items, seeded from the global
// default so behavior is unchanged until the user flips it (M27).
const [enqueueKind, setEnqueueKind] = useState<MediaKind>(
() => useSettings.getState().defaultKind
)
const [batchNote, setBatchNote] = useState<string | null>(null) const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null) const [syncNote, setSyncNote] = useState<string | null>(null)
// Which playlist groups are expanded. Empty = all collapsed (the default), so // Which playlist groups are expanded. Empty = all collapsed (the default), so
@@ -287,7 +277,10 @@ export function LibraryView(): React.JSX.Element {
// Load the current scheduled-sync (Task Scheduler) state once. // Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => { useEffect(() => {
if (PREVIEW) return if (PREVIEW) return
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {}) window.api
.getScheduledSync()
.then((s) => setScheduled(s.enabled))
.catch(logError('getScheduledSync'))
}, []) }, [])
async function onCheckNew(): Promise<void> { async function onCheckNew(): Promise<void> {
@@ -314,7 +307,13 @@ export function LibraryView(): React.JSX.Element {
// Live per-URL queue status so a video row reflects its real download state. // Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => { const statusByUrl = useMemo(() => {
const m = new Map<string, DownloadStatus>() const m = new Map<string, DownloadStatus>()
for (const d of downloadItems) m.set(d.url, d.status) for (const d of downloadItems) {
const prev = m.get(d.url)
// Keep the highest-priority status when a URL has duplicate queue entries.
if (prev === undefined || STATUS_PRIORITY[d.status] > STATUS_PRIORITY[prev]) {
m.set(d.url, d.status)
}
}
return m return m
}, [downloadItems]) }, [downloadItems])
@@ -322,8 +321,7 @@ export function LibraryView(): React.JSX.Element {
const groups = useMemo(() => groupByPlaylist(items), [items]) const groups = useMemo(() => groupByPlaylist(items), [items])
// A group is shown when toggled open, or auto-expanded when it's the only group // A group is shown when toggled open, or auto-expanded when it's the only group
// (a single "Uploads" channel shouldn't need a second click to reach its videos). // (a single "Uploads" channel shouldn't need a second click to reach its videos).
const isGroupOpen = (title: string): boolean => const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
groups.length === 1 || expandedGroups.has(title)
// Flatten groups → [header, ...its items, header, ...] for the virtualized list. // Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => { const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = [] const rows: LibRow[] = []
@@ -360,7 +358,7 @@ export function LibraryView(): React.JSX.Element {
if (!u || indexing.active) return if (!u || indexing.active) return
const res = await indexSource(u) const res = await indexSource(u)
if (res.ok) setUrl('') if (res.ok) setUrl('')
else setError(res.error ?? 'Could not index that link.') else setError(res.error ?? 'Could not add that link.')
} }
function toggle(id: string, on: boolean): void { function toggle(id: string, on: boolean): void {
@@ -385,6 +383,9 @@ export function LibraryView(): React.JSX.Element {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev)
for (const it of groupItems) { for (const it of groupItems) {
// Only actionable rows are selectable, so the selection count can never
// exceed what "Download N selected" will actually queue (M36).
if (!actionable(it)) continue
if (on) next.add(it.id) if (on) next.add(it.id)
else next.delete(it.id) else next.delete(it.id)
} }
@@ -397,18 +398,18 @@ export function LibraryView(): React.JSX.Element {
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set()) setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
} }
// One click queues every chosen item maxConcurrent gates how many actually // One click queues every chosen item -- maxConcurrent gates how many actually
// run, the rest wait in the queue. Selection clears since nothing is held back. // run, the rest wait in the queue. Selection clears since nothing is held back.
function downloadSelected(): void { function downloadSelected(): void {
if (!selectedSourceId || selectedActionable.length === 0) return if (!selectedSourceId || selectedActionable.length === 0) return
const n = enqueueItems(selectedSourceId, selectedActionable) const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
setSelected(new Set()) setSelected(new Set())
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
} }
function downloadPending(): void { function downloadPending(): void {
if (!selectedSourceId || pendingItems.length === 0) return if (!selectedSourceId || pendingItems.length === 0) return
const n = enqueueItems(selectedSourceId, pendingItems) const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
} }
@@ -418,18 +419,11 @@ export function LibraryView(): React.JSX.Element {
if (!selectedSourceId) return if (!selectedSourceId) return
const toQueue = groupItems.filter(actionable) const toQueue = groupItems.filter(actionable)
if (toQueue.length === 0) return if (toQueue.length === 0) return
const n = enqueueItems(selectedSourceId, toQueue) const n = enqueueItems(selectedSourceId, toQueue, enqueueKind)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
} }
function pillClass(status: ItemStatus): string { // One row of the item list -- a playlist header or a video -- shared by the
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
return styles.pill
}
// One row of the item list — a playlist header or a video — shared by the
// inline (small source) and virtualized (large source) render paths. // inline (small source) and virtualized (large source) render paths.
function rowKey(row: LibRow): string { function rowKey(row: LibRow): string {
return row.kind === 'header' ? `h:${row.title}` : row.item.id return row.kind === 'header' ? `h:${row.title}` : row.item.id
@@ -437,32 +431,27 @@ export function LibraryView(): React.JSX.Element {
function renderRow(row: LibRow): React.JSX.Element { function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') { if (row.kind === 'header') {
const allOn = row.items.every((it) => selected.has(it.id))
const open = isGroupOpen(row.title) const open = isGroupOpen(row.title)
const groupActionable = row.items.filter(actionable).length // "All on" is judged over the ACTIONABLE rows only -- downloaded rows aren't
// selectable, so they must not keep the group from reading as fully selected (M36).
const groupActionableItems = row.items.filter(actionable)
const groupActionable = groupActionableItems.length
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
return ( return (
<div <div className={styles.groupHead}>
className={styles.groupHead} <button
onClick={() => toggleGroupExpand(row.title)} type="button"
role="button" className={mergeClasses(styles.groupToggle, focus.focusRing)}
tabIndex={0} onClick={() => toggleGroupExpand(row.title)}
onKeyDown={(e) => aria-expanded={open}
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title) aria-label={`${row.title}, ${row.items.length} items`}
}
aria-expanded={open}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={(e) => {
e.stopPropagation()
toggleGroup(row.items, !allOn)
}}
> >
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<Text className={styles.groupTitle}>{row.title}</Text>
<Caption1 className={text.muted}>{row.items.length}</Caption1>
</button>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
{allOn ? 'None' : 'All'} {allOn ? 'None' : 'All'}
</Button> </Button>
<Button <Button
@@ -470,10 +459,7 @@ export function LibraryView(): React.JSX.Element {
appearance="subtle" appearance="subtle"
icon={<ArrowDownloadRegular />} icon={<ArrowDownloadRegular />}
disabled={groupActionable === 0} disabled={groupActionable === 0}
onClick={(e) => { onClick={() => downloadGroup(row.items)}
e.stopPropagation()
downloadGroup(row.items)
}}
> >
Download{groupActionable > 0 ? ` ${groupActionable}` : ''} Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
</Button> </Button>
@@ -486,6 +472,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.row}> <div className={styles.row}>
<Checkbox <Checkbox
checked={selected.has(it.id)} checked={selected.has(it.id)}
disabled={!actionable(it)}
onChange={(_, d) => toggle(it.id, !!d.checked)} onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`} aria-label={`Select ${it.title}`}
/> />
@@ -496,32 +483,31 @@ export function LibraryView(): React.JSX.Element {
iconSize={16} iconSize={16}
/> />
<div className={styles.rowMain}> <div className={styles.rowMain}>
<span className={styles.rowTitle}> <Text className={text.truncate}>
{it.playlistIndex}. {it.title} {it.playlistIndex}. {it.title}
</span> </Text>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>} {it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
</div> </div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span> <StatusChip status={status} />
</div> </div>
) )
} }
return ( return (
<div className={styles.root}> <div className={mergeClasses(styles.root, screen.width)}>
<div className={styles.header}> <ScreenHeader
<Subtitle2>Library</Subtitle2> title="Library"
<Caption1 className={styles.sub}> description="Add a channel or playlist once, then download its videos into organized folders."
Index a channel or playlist once, then download it into organized folders. />
</Caption1>
</div>
<div className={styles.addRow}> <div className={styles.addRow}>
<Input <Input
className={styles.addInput} className={styles.addInput}
input={{ 'aria-label': 'Channel or playlist URL' }}
value={url} value={url}
onChange={(_, d) => setUrl(d.value)} onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()} onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL…" placeholder="Paste a channel or playlist URL to add it…"
size="large" size="large"
contentBefore={<LibraryRegular />} contentBefore={<LibraryRegular />}
disabled={indexing.active} disabled={indexing.active}
@@ -529,49 +515,51 @@ export function LibraryView(): React.JSX.Element {
<Button <Button
size="large" size="large"
appearance="primary" appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />} icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
onClick={onIndex} onClick={onIndex}
disabled={!url.trim() || indexing.active} disabled={!url.trim() || indexing.active}
> >
Index Add
</Button> </Button>
</div> </div>
{clip.suggestion && ( {clip.suggestion && (
<div className={styles.suggestion}> <LinkSuggestion
<LinkRegular /> prefix="Use copied link? "
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1> link={clip.suggestion}
<Button onAccept={() => {
size="small" const link = clip.accept()
appearance="primary" if (link) setUrl(link)
onClick={() => { }}
const link = clip.accept() onDismiss={clip.dismiss}
if (link) setUrl(link) dismissLabel="Dismiss suggested link"
}} />
>
Use
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={clip.dismiss}
aria-label="Dismiss"
/>
</div>
)} )}
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Hint
size="small" label={
appearance="secondary" watchedCount === 0
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />} ? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
onClick={onCheckNew} : 'Check your watched sources for new uploads'
disabled={syncing || watchedCount === 0} }
placement="top"
align="start"
> >
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new <Button
</Button> size="small"
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>} appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
// disabledFocusable (not disabled) when nothing is watched, so the
// button still shows its explanatory tooltip on hover/focus (UX24).
disabled={syncing}
disabledFocusable={watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
</Hint>
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} /> <div className={styles.toolbarSpacer} />
<span className={styles.switchRow}> <span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1> <Caption1>Auto-download new</Caption1>
@@ -595,7 +583,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.progress}> <div className={styles.progress}>
<Spinner size="tiny" /> <Spinner size="tiny" />
<Text> <Text>
{indexing.message ?? 'Indexing…'} {indexing.message ?? 'Adding…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''} {indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text> </Text>
</div> </div>
@@ -603,10 +591,10 @@ export function LibraryView(): React.JSX.Element {
{error && <Caption1 className={styles.error}>{error}</Caption1>} {error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? ( {sources.length === 0 && !indexing.active ? (
<div className={styles.empty}> <EmptyState
<LibraryRegular fontSize={40} /> icon={<LibraryRegular fontSize={ICON.hero} />}
<Body1>No channels or playlists yet. Paste one above to index it.</Body1> message="No channels or playlists yet. Paste one above to add it."
</div> />
) : ( ) : (
<div className={styles.list}> <div className={styles.list}>
{sources.map((src) => ( {sources.map((src) => (
@@ -615,9 +603,7 @@ export function LibraryView(): React.JSX.Element {
styles={styles} styles={styles}
source={src} source={src}
expanded={selectedSourceId === src.id} expanded={selectedSourceId === src.id}
onToggleExpand={() => onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
selectSource(selectedSourceId === src.id ? null : src.id)
}
> >
<div className={styles.detail}> <div className={styles.detail}>
<div className={styles.actionRow}> <div className={styles.actionRow}>
@@ -627,14 +613,27 @@ export function LibraryView(): React.JSX.Element {
label="Select all" label="Select all"
disabled={actionableItems.length === 0} disabled={actionableItems.length === 0}
/> />
<Caption1 className={styles.srcSub}> <Caption1 className={text.muted}>
{items.length} videos · {pendingItems.length} pending · indexed{' '} {items.length} videos{META_SEP}
{relTime(src.lastIndexedAt)} {pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
</Caption1> </Caption1>
<div className={styles.actionSpacer} /> <div className={styles.actionSpacer} />
<SegmentedControl<MediaKind>
value={enqueueKind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={setEnqueueKind}
ariaLabel="Download as video or audio"
/>
{selected.size > 0 ? ( {selected.size > 0 ? (
<> <>
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}> <Button
size="small"
appearance="subtle"
onClick={() => setSelected(new Set())}
>
Clear Clear
</Button> </Button>
<Button <Button
@@ -673,19 +672,43 @@ export function LibraryView(): React.JSX.Element {
onClick={() => reindexSource(src.id)} onClick={() => reindexSource(src.id)}
disabled={indexing.active} disabled={indexing.active}
> >
Re-index Refresh
</Button>
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => removeSource(src.id)}
>
Remove
</Button> </Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
removeSource(src.id)
setConfirmRemoveId(null)
}}
>
Remove
</Button>
<Button
size="small"
appearance="subtle"
onClick={() => setConfirmRemoveId(null)}
>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmRemoveId(src.id)}
>
Remove
</Button>
)}
</div> </div>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>} {batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? ( {flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized // Big source: a fixed-height, internally-scrolling virtualized
@@ -695,7 +718,7 @@ export function LibraryView(): React.JSX.Element {
items={flatRows} items={flatRows}
style={{ height: '58vh' }} style={{ height: '58vh' }}
overscan={10} overscan={10}
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)} estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)} getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)} renderItem={(row) => renderRow(row)}
/> />
@@ -730,15 +753,16 @@ function SourceCard({
onToggleExpand: () => void onToggleExpand: () => void
children: React.ReactNode children: React.ReactNode
}): React.JSX.Element { }): React.JSX.Element {
const focus = useFocusStyles()
const text = useTextStyles()
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div <button
className={styles.cardHead} type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand} onClick={onToggleExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
aria-expanded={expanded} aria-expanded={expanded}
aria-label={source.title}
> >
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />} {expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}> <div className={styles.srcIcon}>
@@ -746,19 +770,23 @@ function SourceCard({
</div> </div>
<div className={styles.srcMeta}> <div className={styles.srcMeta}>
<span className={styles.srcTitleRow}> <span className={styles.srcTitleRow}>
<span className={styles.srcTitle}>{source.title}</span> <Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && ( {source.watched && (
<span className={styles.watchBadge}> <span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching <AlertRegular fontSize={11} /> Watching
</span> </span>
)} )}
</span> </span>
<Caption1 className={styles.srcSub}> <Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos {source.kind === 'channel' ? 'Channel' : 'Playlist'}
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''} {META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1> </Caption1>
</div> </div>
</div> </button>
{expanded && children} {expanded && children}
</div> </div>
) )
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles } from '@fluentui/react-components'
import { useDownloads, type DownloadStatus } from '../store/downloads'
const useStyles = makeStyles({
// Visually hidden, but present for screen readers (the standard sr-only recipe).
srOnly: {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap'
}
})
/**
* A polite ARIA live region (W17) so Narrator announces download completions and
* failures even when the user isn't on the Downloads tab. Subscribes to the store
* directly and announces only terminal transitions ( completed / error), so it
* never chatters on progress ticks. Mounted once, near the app root.
*/
export function LiveRegion(): React.JSX.Element {
const styles = useStyles()
const [message, setMessage] = useState('')
const prev = useRef<Map<string, DownloadStatus>>(new Map())
useEffect(() => {
function check(items: ReturnType<typeof useDownloads.getState>['items']): void {
const announcements: string[] = []
for (const it of items) {
if (prev.current.get(it.id) !== it.status) {
if (it.status === 'completed') announcements.push(`Finished downloading ${it.title}`)
else if (it.status === 'error') announcements.push(`Download failed: ${it.title}`)
}
}
prev.current = new Map(items.map((i) => [i.id, i.status]))
if (announcements.length > 0) setMessage(announcements.join('. '))
}
// Seed the baseline without announcing the items already present on mount.
prev.current = new Map(useDownloads.getState().items.map((i) => [i.id, i.status]))
return useDownloads.subscribe((st) => check(st.items))
}, [])
return (
<div className={styles.srOnly} role="status" aria-live="polite" aria-atomic="true">
{message}
</div>
)
}
+2 -8
View File
@@ -41,7 +41,7 @@ export function MediaThumb({
}): React.JSX.Element { }): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const isDark = useResolvedDark() const isDark = useResolvedDark()
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video'] const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
const [failedSrc, setFailedSrc] = useState<string | null>(null) const [failedSrc, setFailedSrc] = useState<string | null>(null)
const showImg = !!src && failedSrc !== src const showImg = !!src && failedSrc !== src
@@ -53,13 +53,7 @@ export function MediaThumb({
style={{ backgroundColor: colors.bg, color: colors.fg }} style={{ backgroundColor: colors.bg, color: colors.fg }}
> >
{showImg ? ( {showImg ? (
<img <img className={styles.img} src={src} alt="" onError={() => setFailedSrc(src ?? null)} />
className={styles.img}
src={src}
alt=""
loading="lazy"
onError={() => setFailedSrc(src ?? null)}
/>
) : kind === 'audio' ? ( ) : kind === 'audio' ? (
<MusicNote2Regular fontSize={iconSize} /> <MusicNote2Regular fontSize={iconSize} />
) : ( ) : (
+85 -13
View File
@@ -6,6 +6,7 @@ import {
Field, Field,
Button, Button,
makeStyles, makeStyles,
mergeClasses,
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
@@ -14,9 +15,14 @@ import {
ClipboardPasteRegular, ClipboardPasteRegular,
HistoryRegular, HistoryRegular,
OptionsRegular, OptionsRegular,
RocketRegular RocketRegular,
FolderRegular,
MusicNote2Regular,
VideoClipRegular,
KeyboardRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -26,13 +32,14 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflowY: 'auto', overflowY: 'auto',
padding: '24px' padding: SPACE.page
}, },
card: { card: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '20px', gap: SPACE.roomy,
padding: '32px', // The welcome hero card (UI3 hero tier).
padding: SPACE.hero,
maxWidth: '460px', maxWidth: '460px',
width: '100%', width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge) ...shorthands.borderRadius(tokens.borderRadiusXLarge)
@@ -52,10 +59,45 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '26px' // Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.section}px`
}, },
folderNote: { folderNote: {
color: tokens.colorNeutralForeground3 color: tokens.colorNeutralForeground3,
marginBottom: SPACE.tight
},
folderRow: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
folderRowGap: {
marginTop: SPACE.tight
},
folderIcon: {
fontSize: `${ICON.control}px`,
flexShrink: 0,
color: tokens.colorCompoundBrandForeground1
},
folderCol: {
display: 'flex',
flexDirection: 'column',
minWidth: 0,
flexGrow: 1
},
folderLabel: {
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1
},
folderPath: {
color: tokens.colorNeutralForeground3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}, },
tips: { tips: {
display: 'flex', display: 'flex',
@@ -68,9 +110,9 @@ const useStyles = makeStyles({
gap: '10px' gap: '10px'
}, },
tipIcon: { tipIcon: {
fontSize: '18px', fontSize: `${ICON.control}px`,
flexShrink: 0, flexShrink: 0,
marginTop: '2px', marginTop: SPACE.hairline,
color: tokens.colorCompoundBrandForeground1 color: tokens.colorCompoundBrandForeground1
} }
}) })
@@ -78,7 +120,7 @@ const useStyles = makeStyles({
const TIPS: { icon: React.JSX.Element; text: string }[] = [ const TIPS: { icon: React.JSX.Element; text: string }[] = [
{ {
icon: <ClipboardPasteRegular />, icon: <ClipboardPasteRegular />,
text: 'Copy a video link and AeroFetch offers to fetch it as soon as you switch back.' text: 'Turn on clipboard detection in Settings and AeroFetch will offer to fetch links you copy.'
}, },
{ {
icon: <HistoryRegular />, icon: <HistoryRegular />,
@@ -87,12 +129,19 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
{ {
icon: <OptionsRegular />, icon: <OptionsRegular />,
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.' text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
},
{
icon: <KeyboardRegular />,
text: 'Press Ctrl+K anytime to jump to any screen or start a new download.'
} }
] ]
export function Onboarding(): React.JSX.Element { export function Onboarding(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
return ( return (
<div className={styles.root}> <div className={styles.root}>
@@ -101,7 +150,7 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}> <div className={styles.mark}>
<ArrowDownloadFilled /> <ArrowDownloadFilled />
</div> </div>
<Title2>Welcome to AeroFetch</Title2> <Title2 as="h1">Welcome to AeroFetch</Title2>
</div> </div>
<Body1> <Body1>
@@ -111,10 +160,33 @@ export function Onboarding(): React.JSX.Element {
<Field label="Where downloads go"> <Field label="Where downloads go">
<Caption1 className={styles.folderNote}> <Caption1 className={styles.folderNote}>
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '} Videos and audio save to your Documents folders by default. Pick a different folder now,
<strong>Documents\Audio</strong>. You can point each to a different folder any time or change it any time in Settings.
in Settings.
</Caption1> </Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
{videoDir || 'Documents\\Video'}
</Caption1>
</div>
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Choose
</Button>
</div>
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
{audioDir || 'Documents\\Audio'}
</Caption1>
</div>
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Choose
</Button>
</div>
</Field> </Field>
<div className={styles.tips}> <div className={styles.tips}>
+119 -68
View File
@@ -1,12 +1,12 @@
import { memo } from 'react' import { memo } from 'react'
import { import {
Text, Text,
Caption1, Caption1,
Button, Button,
ProgressBar, ProgressBar,
Badge,
Spinner, Spinner,
makeStyles, makeStyles,
mergeClasses,
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
@@ -25,28 +25,38 @@ import {
ErrorCircleFilled, ErrorCircleFilled,
EyeOffRegular EyeOffRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads' import { useShallow } from 'zustand/react/shallow'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../thumb'
import { fmtSchedule } from '../datetime'
import { fmtSpeed, fmtEta } from '@shared/format'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint' import { Hint } from './Hint'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_MD } from '../thumbSizes'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
gap: '14px', gap: '14px',
padding: '14px', // List-item card: card padding + list-item radius (UI3/UI7).
padding: SPACE.section,
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge), ...shorthands.borderRadius(RADIUS.surface),
border: `1px solid ${tokens.colorNeutralStroke2}` border: `1px solid ${tokens.colorNeutralStroke2}`
}, },
thumb: { thumb: {
flexShrink: 0, flexShrink: 0,
width: '108px', width: `${THUMB_MD.w}px`,
height: '64px', height: `${THUMB_MD.h}px`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusLarge) // One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
}, },
body: { body: {
flexGrow: 1, flexGrow: 1,
@@ -60,20 +70,11 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
gap: '8px' gap: '8px'
}, },
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
progressRow: { progressRow: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
marginTop: '4px' marginTop: SPACE.xtight
}, },
progressBar: { progressBar: {
flexGrow: 1 flexGrow: 1
@@ -88,63 +89,88 @@ const useStyles = makeStyles({
actions: { actions: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: SPACE.xtight,
flexShrink: 0 flexShrink: 0,
// Icon-only row actions get a >=40px hit target for touch/pen (W16); the
// glyph stays its normal size, the subtle button just carries more padding.
'& button': {
minWidth: '40px',
minHeight: '40px'
}
} }
}) })
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' },
paused: { label: 'Paused', color: 'warning' },
saved: { label: 'Saved', color: 'subtle' },
completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'warning' }
}
function pct(progress: number): string { function pct(progress: number): string {
return `${Math.round(progress * 100)}%` return `${Math.round(progress * 100)}%`
} }
function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}
export const QueueItem = memo(function QueueItem({ export const QueueItem = memo(function QueueItem({
item item
}: { }: {
item: DownloadItem item: DownloadItem
}): React.JSX.Element { }): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const cancel = useDownloads((s) => s.cancel) const focus = useFocusStyles()
const pause = useDownloads((s) => s.pause) const text = useTextStyles()
const resume = useDownloads((s) => s.resume) // One shallow-compared selection instead of 10 separate subscriptions per row
const prioritize = useDownloads((s) => s.prioritize) // (L163). The actions are stable, so this never re-renders from the store.
const saveForLater = useDownloads((s) => s.saveForLater) const {
const queueNow = useDownloads((s) => s.queueNow) cancel,
const remove = useDownloads((s) => s.remove) pause,
const retry = useDownloads((s) => s.retry) resume,
const openFile = useDownloads((s) => s.openFile) prioritize,
const showInFolder = useDownloads((s) => s.showInFolder) saveForLater,
queueNow,
remove,
retry,
openFile,
showInFolder
} = useDownloads(
useShallow((s) => ({
cancel: s.cancel,
pause: s.pause,
resume: s.resume,
prioritize: s.prioritize,
saveForLater: s.saveForLater,
queueNow: s.queueNow,
remove: s.remove,
retry: s.retry,
openFile: s.openFile,
showInFolder: s.showInFolder
}))
)
const badge = STATUS_BADGE[item.status]
const active = item.status === 'downloading' || item.status === 'queued' const active = item.status === 'downloading' || item.status === 'queued'
// W7: Delete on a focused row removes it -- cancelling first if it's still running,
// since an in-flight download can't just be dropped from the list. Only when the
// row itself holds focus, so Delete on one of its action buttons is unaffected.
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
if (active) cancel(item.id)
else remove(item.id)
}
}
// Format the raw progress numbers for display (H4: main sends bytes/sec + secs,
// the renderer owns formatting via the one shared formatter).
const speedLabel = fmtSpeed(item.speedBytesPerSec)
const etaLabel = fmtEta(item.etaSeconds)
// L55: a probed-format quality label already carries the size ("720p · mp4 ·
// 184 MB"); skip sizeLabel when it's already embedded to avoid showing it twice.
const sizeAlreadyInQuality = item.sizeLabel && item.quality.includes(item.sizeLabel)
const metaParts = [ const metaParts = [
item.channel, item.channel,
item.durationLabel, item.durationLabel,
item.quality, item.quality,
item.kind === 'audio' ? 'Audio' : 'Video', item.kind === 'audio' ? 'Audio' : 'Video',
item.sizeLabel !sizeAlreadyInQuality ? item.sizeLabel : undefined
].filter(Boolean) ].filter(Boolean)
return ( return (
<div className={styles.root}> <div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
<MediaThumb <MediaThumb
className={styles.thumb} className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })} src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
@@ -156,57 +182,82 @@ export const QueueItem = memo(function QueueItem({
<div className={styles.titleRow}> <div className={styles.titleRow}>
{item.status === 'completed' && ( {item.status === 'completed' && (
<CheckmarkCircleFilled <CheckmarkCircleFilled
fontSize={16} fontSize={ICON.inline}
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }} style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
/> />
)} )}
{item.status === 'error' && ( {item.status === 'error' && (
<ErrorCircleFilled <ErrorCircleFilled
fontSize={16} fontSize={ICON.inline}
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }} style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
/> />
)} )}
<Text className={styles.title}>{item.title}</Text> <Text className={text.title}>{item.title}</Text>
<Badge appearance="tint" color={badge.color}> <StatusChip status={item.status} />
{badge.label}
</Badge>
{item.incognito && ( {item.incognito && (
<Hint label="Private not saved to history" placement="top" align="start"> <Hint label="Private -- not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} /> <EyeOffRegular
fontSize={ICON.inline}
style={{ color: tokens.colorNeutralForeground3 }}
/>
</Hint> </Hint>
)} )}
</div> </div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1> <Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
{item.status === 'downloading' && ( {item.status === 'downloading' && (
<div className={styles.progressRow}> <div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" /> {/* Indeterminate when finishing (the second stream / merge reads as
"working" rather than a 0100% restart, SR7) or when yt-dlp can't
report a total size, so the bar never sits frozen at 0% (L137). */}
<ProgressBar
className={styles.progressBar}
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`}
/>
<Caption1 className={styles.stats}> <Caption1 className={styles.stats}>
{pct(item.progress)} {item.finishing ? (
{item.speed ? `${item.speed}` : ''} 'Finishing…'
{item.eta ? `${item.eta} left` : ''} ) : (
<>
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
{speedLabel ? `${META_SEP}${speedLabel}` : ''}
{etaLabel ? `${META_SEP}${etaLabel} left` : ''}
</>
)}
</Caption1> </Caption1>
</div> </div>
)} )}
{item.status === 'queued' && ( {item.status === 'queued' && (
<div className={styles.progressRow}> <div className={styles.progressRow}>
<Spinner size="extra-tiny" /> <Spinner size="tiny" />
<Caption1 className={styles.stats}>Waiting to start</Caption1> <Caption1 className={styles.stats}>Waiting to start</Caption1>
</div> </div>
)} )}
{item.status === 'paused' && ( {item.status === 'paused' && (
<div className={styles.progressRow}> <div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" /> <ProgressBar
<Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1> className={styles.progressBar}
value={item.progress}
thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`}
/>
<Caption1 className={styles.stats}>
Paused{META_SEP}
{pct(item.progress)}
</Caption1>
</div> </div>
)} )}
{item.status === 'saved' && ( {item.status === 'saved' && (
<Caption1 className={styles.stats}> <Caption1 className={styles.stats}>
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'} {item.scheduledFor
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
: 'Saved for later'}
</Caption1> </Caption1>
)} )}
+42 -5
View File
@@ -1,9 +1,15 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import {
makeStyles,
mergeClasses,
tokens,
shorthands,
useFieldControlProps_unstable
} from '@fluentui/react-components'
// A native <select> styled to match Fluent inputs. We use this instead of // A native <select> styled to match Fluent inputs. We use this instead of
// Fluent's <Dropdown> because Fluent's portal/popover menu is a composited // Fluent's <Dropdown> because Fluent's portal/popover menu is a composited
// overlay in the WebContents, and on this dev machine's GPU/driver that overlay // overlay in the WebContents, and on this dev machine's GPU/driver that overlay
// paint blanks the whole window (same family as the documented flicker // paint blanks the whole window (same family as the documented flicker --
// hardware acceleration is already disabled; see memory "aerofetch-gpu-flicker"). // hardware acceleration is already disabled; see memory "aerofetch-gpu-flicker").
// The native <select> popup is drawn by the OS, so it can't trigger the blank. // The native <select> popup is drawn by the OS, so it can't trigger the blank.
// The popup's light/dark styling follows the `color-scheme` set on the app root // The popup's light/dark styling follows the `color-scheme` set on the app root
@@ -27,6 +33,20 @@ const useStyles = makeStyles({
outline: 'none', outline: 'none',
...shorthands.borderColor(tokens.colorCompoundBrandStroke) ...shorthands.borderColor(tokens.colorCompoundBrandStroke)
} }
},
large: {
height: '40px',
padding: '0 12px',
fontSize: tokens.fontSizeBase400
},
disabled: {
cursor: 'not-allowed',
color: tokens.colorNeutralForegroundDisabled,
backgroundColor: tokens.colorNeutralBackgroundDisabled,
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled),
':hover': {
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled)
}
} }
}) })
@@ -41,6 +61,8 @@ interface SelectProps {
onChange: (value: string) => void onChange: (value: string) => void
className?: string className?: string
'aria-label'?: string 'aria-label'?: string
size?: 'medium' | 'large'
disabled?: boolean
} }
export function Select({ export function Select({
@@ -48,15 +70,30 @@ export function Select({
options, options,
onChange, onChange,
className, className,
'aria-label': ariaLabel 'aria-label': ariaLabel,
size,
disabled
}: SelectProps): React.JSX.Element { }: SelectProps): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
// Integrate with a wrapping Fluent <Field> (L133): pick up the id + label/hint
// association so the Field's visible label names the select natively (via
// `htmlFor`), instead of every call site repeating the name in an `aria-label`.
// Outside a Field this returns the props unchanged, so standalone selects keep
// their own `aria-label`. `supportsLabelFor` = native <label for>, not aria-labelledby.
const fieldProps = useFieldControlProps_unstable({}, { supportsLabelFor: true })
return ( return (
<select <select
className={mergeClasses(styles.select, className)} {...fieldProps}
aria-label={ariaLabel}
className={mergeClasses(
styles.select,
size === 'large' && styles.large,
disabled && styles.disabled,
className
)}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel} disabled={disabled}
> >
{options.map((o) => ( {options.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
File diff suppressed because it is too large Load Diff
+81 -100
View File
@@ -1,4 +1,11 @@
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' import {
Caption1,
CounterBadge,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { import {
ArrowDownloadFilled, ArrowDownloadFilled,
ArrowDownloadRegular, ArrowDownloadRegular,
@@ -14,6 +21,11 @@ import {
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc' import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint' import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { MOTION, ICON } from './ui/tokens'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings' export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
@@ -27,7 +39,8 @@ const useStyles = makeStyles({
padding: '16px 12px', padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`, borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
transition: 'width 0.15s ease' // The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
transition: `width ${MOTION.duration} ${MOTION.curve}`
}, },
rootCollapsed: { rootCollapsed: {
width: '60px', width: '60px',
@@ -41,24 +54,6 @@ const useStyles = makeStyles({
topBarCollapsed: { topBarCollapsed: {
justifyContent: 'center' 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: { brand: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -79,7 +74,8 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '20px' // Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
}, },
brandText: { brandText: {
display: 'flex', display: 'flex',
@@ -92,9 +88,6 @@ const useStyles = makeStyles({
lineHeight: tokens.lineHeightBase400, lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1 color: tokens.colorNeutralForeground1
}, },
caption: {
color: tokens.colorNeutralForeground3
},
nav: { nav: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -102,6 +95,8 @@ const useStyles = makeStyles({
alignSelf: 'stretch' alignSelf: 'stretch'
}, },
navItem: { navItem: {
// relative so the collapsed count badge can pin to the icon's corner.
position: 'relative',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '11px', gap: '11px',
@@ -119,6 +114,16 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover backgroundColor: tokens.colorNeutralBackground1Hover
} }
}, },
// Expanded: the active-download count sits at the far right of the nav row.
navBadge: {
marginLeft: 'auto'
},
// Collapsed: pin the count to the top-right corner of the centered icon.
navBadgeCollapsed: {
position: 'absolute',
top: '4px',
right: '8px'
},
navItemCollapsed: { navItemCollapsed: {
justifyContent: 'center', justifyContent: 'center',
padding: '9px 0' padding: '9px 0'
@@ -132,47 +137,12 @@ const useStyles = makeStyles({
} }
}, },
navIcon: { navIcon: {
fontSize: '18px', fontSize: `${ICON.control}px`,
flexShrink: 0, flexShrink: 0,
display: 'flex' display: 'flex'
}, },
spacer: { spacer: {
flexGrow: 1 flexGrow: 1
},
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
themeGroup: {
alignSelf: 'stretch',
display: 'flex',
width: '100%',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
themeSeg: {
flex: 1,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
padding: '7px 4px',
fontSize: tokens.fontSizeBase200,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
themeSegActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
} }
}) })
@@ -202,6 +172,10 @@ interface SidebarProps {
version: string version: string
collapsed: boolean collapsed: boolean
onToggleCollapsed: () => void onToggleCollapsed: () => void
/** Show the Terminal nav item (only when custom commands are enabled) */
showTerminal: boolean
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
downloadCount: number
} }
export function Sidebar({ export function Sidebar({
@@ -212,22 +186,27 @@ export function Sidebar({
onSetTheme, onSetTheme,
version, version,
collapsed, collapsed,
onToggleCollapsed onToggleCollapsed,
showTerminal,
downloadCount
}: SidebarProps): React.JSX.Element { }: SidebarProps): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const focus = useFocusStyles()
const text = useTextStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto. // Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system'] const order: ThemeMode[] = ['light', 'dark', 'system']
function cycleTheme(): void { function cycleTheme(): void {
onSetTheme(order[(order.indexOf(theme) + 1) % order.length]) const next = order[(order.indexOf(theme) + 1) % order.length]
if (next) onSetTheme(next)
} }
const themeIcon = const themeIcon =
theme === 'system' ? ( theme === 'system' ? (
<DesktopRegular fontSize={18} /> <DesktopRegular fontSize={ICON.control} />
) : isDark ? ( ) : isDark ? (
<WeatherMoonRegular fontSize={18} /> <WeatherMoonRegular fontSize={ICON.control} />
) : ( ) : (
<WeatherSunnyRegular fontSize={18} /> <WeatherSunnyRegular fontSize={ICON.control} />
) )
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light' const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
@@ -235,15 +214,12 @@ export function Sidebar({
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}> <nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}> <div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right"> <Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<button <IconButton
type="button" icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
className={styles.iconBtn}
onClick={onToggleCollapsed} onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed} aria-pressed={collapsed}
> />
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
</Hint> </Hint>
</div> </div>
@@ -254,13 +230,18 @@ export function Sidebar({
{!collapsed && ( {!collapsed && (
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span> <span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1> {/* Stable tagline so the caption never flips from text to a version
string once it loads (L66); the version shows on hover and in
Settings About. */}
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
Video downloader
</Caption1>
</div> </div>
)} )}
</div> </div>
<div className={styles.nav}> <div className={styles.nav}>
{NAV.map((n) => { {NAV.filter((n) => n.value !== 'terminal' || showTerminal).map((n) => {
const active = tab === n.value const active = tab === n.value
const btn = ( const btn = (
<button <button
@@ -269,7 +250,8 @@ export function Sidebar({
className={mergeClasses( className={mergeClasses(
styles.navItem, styles.navItem,
collapsed && styles.navItemCollapsed, collapsed && styles.navItemCollapsed,
active && styles.navItemActive active && styles.navItemActive,
focus.focusRing
)} )}
style={ style={
active && !collapsed active && !collapsed
@@ -278,10 +260,23 @@ export function Sidebar({
} }
onClick={() => onTabChange(n.value)} onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
aria-label={n.label} aria-label={
n.value === 'downloads' && downloadCount > 0
? `${n.label}, ${downloadCount} active`
: n.label
}
> >
<span className={styles.navIcon}>{n.icon}</span> <span className={styles.navIcon}>{n.icon}</span>
{!collapsed && n.label} {!collapsed && n.label}
{n.value === 'downloads' && downloadCount > 0 && (
<CounterBadge
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
count={downloadCount}
size="small"
color="brand"
aria-hidden
/>
)}
</button> </button>
) )
return collapsed ? ( return collapsed ? (
@@ -298,34 +293,20 @@ export function Sidebar({
{collapsed ? ( {collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right"> <Hint label={`Theme: ${themeLabel}`} placement="right">
<button <IconButton
type="button" icon={themeIcon}
className={styles.iconBtn}
onClick={cycleTheme} onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`} aria-label={`Change theme (currently ${themeLabel})`}
> />
{themeIcon}
</button>
</Hint> </Hint>
) : ( ) : (
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme"> <SegmentedControl<ThemeMode>
{THEMES.map((t) => { fitted
const on = theme === t.value value={theme}
return ( options={THEMES}
<button onChange={onSetTheme}
key={t.value} ariaLabel="Theme"
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> </nav>
) )
+39 -17
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
Button, Button,
Field,
Input, Input,
Textarea, Textarea,
Caption1, Caption1,
@@ -9,10 +10,18 @@ import {
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons' import {
AddRegular,
EditRegular,
DeleteRegular,
SaveRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc' import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates' import { useTemplates } from '../store/templates'
import { newId } from '../id'
import { Hint } from './Hint' import { Hint } from './Hint'
import { EmptyState } from './ui/EmptyState'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -63,9 +72,6 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
gap: '8px', gap: '8px',
justifyContent: 'flex-end' justifyContent: 'flex-end'
},
empty: {
color: tokens.colorNeutralForeground3
} }
}) })
@@ -79,10 +85,6 @@ interface Draft {
} }
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' } const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
}
/** /**
* Inline (no-modal see the Hint/Select comments re: composited overlays * Inline (no-modal see the Hint/Select comments re: composited overlays
* flickering on this dev machine's GPU) CRUD list + add/edit form for * flickering on this dev machine's GPU) CRUD list + add/edit form for
@@ -100,13 +102,24 @@ export function TemplateManager(): React.JSX.Element {
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' }) setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
} }
function patternError(): string | undefined {
const p = draft?.urlPattern.trim()
if (!p) return undefined
try {
new RegExp(p)
return undefined
} catch {
return 'Invalid regex — this pattern will never match.'
}
}
function commit(): void { function commit(): void {
if (!draft) return if (!draft) return
const name = draft.name.trim() const name = draft.name.trim()
if (!name) return if (!name || patternError()) return
const urlPattern = draft.urlPattern.trim() const urlPattern = draft.urlPattern.trim()
save({ save({
id: draft.id ?? newId(), id: draft.id ?? newId('tpl'),
name, name,
args: draft.args.trim(), args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {}) ...(urlPattern ? { urlPattern } : {})
@@ -117,7 +130,7 @@ export function TemplateManager(): React.JSX.Element {
return ( return (
<div className={styles.root}> <div className={styles.root}>
{templates.length === 0 && !draft && ( {templates.length === 0 && !draft && (
<Caption1 className={styles.empty}>No custom command templates yet.</Caption1> <EmptyState compact message="No custom command templates yet." />
)} )}
{templates.map((t) => ( {templates.map((t) => (
@@ -162,11 +175,16 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical" resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })} onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/> />
<Input <Field
value={draft.urlPattern} validationState={patternError() ? 'error' : 'none'}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com" validationMessage={patternError()}
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })} >
/> <Input
value={draft.urlPattern}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
/>
</Field>
<div className={styles.formActions}> <div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}> <Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel Cancel
@@ -182,7 +200,11 @@ export function TemplateManager(): React.JSX.Element {
</div> </div>
</div> </div>
) : ( ) : (
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}> <Button
appearance="subtle"
icon={<AddRegular />}
onClick={() => setDraft({ ...BLANK_DRAFT })}
>
Add template Add template
</Button> </Button>
)} )}
+71 -37
View File
@@ -1,28 +1,42 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
Button, Button,
Textarea, Textarea,
Subtitle2,
Body1, Body1,
Caption1, Caption1,
makeStyles, makeStyles,
mergeClasses,
tokens, tokens,
shorthands shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons' import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line { interface Line {
id: number
text: string text: string
kind: LineKind kind: LineKind
} }
let lineSeq = 0
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
// lines in seconds. Cap the visible log to prevent unbounded React state growth
// and keep the <pre> scrollable (H6); older lines are dropped from the front.
const MAX_LOG_LINES = 2000
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }, root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
gate: { gate: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '8px',
padding: '12px 14px', padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1, backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1, color: tokens.colorStatusWarningForeground1,
@@ -48,24 +62,24 @@ const useStyles = makeStyles({
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word' wordBreak: 'break-word'
}, },
// Center the "no output yet" placeholder in the log box, so it reads as an
// empty state rather than a stray line of text at the top (L117).
logEmpty: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold }, cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 }, stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 }, sys: { color: tokens.colorNeutralForeground3 }
empty: { color: tokens.colorNeutralForeground3 }
}) })
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
}
/** /**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled * Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent * binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main see src/main/terminal.ts). * flag (enforced again in main -- see src/main/terminal.ts).
*/ */
export function TerminalView(): React.JSX.Element { export function TerminalView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled) const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('') const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([]) const [lines, setLines] = useState<Line[]>([])
@@ -79,13 +93,15 @@ export function TerminalView(): React.JSX.Element {
window.api.onTerminalOutput((ev) => { window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return if (ev.id !== runId.current) return
if (ev.type === 'output') { if (ev.type === 'output') {
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }]) setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
} else if (ev.type === 'error') { } else if (ev.type === 'error') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }]) setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
setRunning(false) setRunning(false)
runId.current = null runId.current = null
} else { } else {
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }]) setLines((ls) =>
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
)
setRunning(false) setRunning(false)
runId.current = null runId.current = null
} }
@@ -101,21 +117,21 @@ export function TerminalView(): React.JSX.Element {
function run(): void { function run(): void {
const a = args.trim() const a = args.trim()
if (!a || running) return if (!a || running) return
const id = newId() const id = newId('t')
runId.current = id runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }]) setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true) setRunning(true)
window.api window.api
.runTerminal(id, a) .runTerminal(id, a)
.then((res) => { .then((res) => {
if (!res.ok) { if (!res.ok) {
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }]) setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
setRunning(false) setRunning(false)
runId.current = null runId.current = null
} }
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }]) setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false) setRunning(false)
runId.current = null runId.current = null
}) })
@@ -126,29 +142,47 @@ export function TerminalView(): React.JSX.Element {
} }
const lineClass = (kind: LineKind): string | undefined => const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined kind === 'cmd'
? styles.cmd
: kind === 'stderr'
? styles.stderr
: kind === 'sys'
? styles.sys
: undefined
return ( return (
<div className={styles.root}> <div className={mergeClasses(styles.root, screen.width)}>
<div className={styles.header}> <ScreenHeader
<Subtitle2>Terminal</Subtitle2> title="Terminal"
<Caption1 className={styles.sub}> description={
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} <>
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically. Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
</Caption1> <code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</div> </>
}
/>
{!customCommandEnabled && ( {!customCommandEnabled && (
<Body1 className={styles.gate}> <div className={styles.gate}>
The terminal is part of custom commands. Turn on Run custom commands in Settings <Body1>
Custom commands to use it. The terminal runs the bundled yt-dlp with your own arguments part of custom commands,
</Body1> which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
</Body1>
<Button
appearance="primary"
size="small"
onClick={() => updateSettings({ customCommandEnabled: true })}
>
Enable custom commands
</Button>
</div>
)} )}
<div className={styles.inputRow}> <div className={styles.inputRow}>
<div className={styles.inputCol}> <div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1> <Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea <Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args} value={args}
onChange={(_, d) => setArgs(d.value)} onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -164,7 +198,7 @@ export function TerminalView(): React.JSX.Element {
</div> </div>
<div className={styles.buttons}> <div className={styles.buttons}>
{running ? ( {running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}> <Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop Stop
</Button> </Button>
) : ( ) : (
@@ -187,12 +221,12 @@ export function TerminalView(): React.JSX.Element {
</div> </div>
</div> </div>
<pre className={styles.log} ref={logRef}> <pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
{lines.length === 0 ? ( {lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span> <EmptyState compact message="No output yet." hint="Run yt-dlp above to see its output." />
) : ( ) : (
lines.map((l, i) => ( lines.map((l) => (
<div key={i} className={lineClass(l.kind)}> <div key={l.id} className={lineClass(l.kind)}>
{l.text} {l.text}
</div> </div>
)) ))
+24 -17
View File
@@ -42,28 +42,35 @@ export function VirtualList<T>({
estimateSize, estimateSize,
overscan, overscan,
gap, gap,
getItemKey: (index) => getKey(items[index], index) getItemKey: (index) => {
const it = items[index]
return it !== undefined ? getKey(it, index) : index
}
}) })
return ( return (
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}> <div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((vrow) => ( {virtualizer.getVirtualItems().map((vrow) => {
<div const item = items[vrow.index]
key={vrow.key} if (item === undefined) return null
data-index={vrow.index} return (
ref={virtualizer.measureElement} <div
style={{ key={vrow.key}
position: 'absolute', data-index={vrow.index}
top: 0, ref={virtualizer.measureElement}
left: 0, style={{
width: '100%', position: 'absolute',
transform: `translateY(${vrow.start}px)` top: 0,
}} left: 0,
> width: '100%',
{renderItem(items[vrow.index], vrow.index)} transform: `translateY(${vrow.start}px)`
</div> }}
))} >
{renderItem(item, vrow.index)}
</div>
)
})}
</div> </div>
</div> </div>
) )
@@ -0,0 +1,97 @@
import { Button, Checkbox, Caption1, mergeClasses } from '@fluentui/react-components'
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import { type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads'
import { Hint } from '../Hint'
import { IconButton } from '../ui/IconButton'
import { useTextStyles } from '../ui/text'
import { META_SEP } from '../ui/tokens'
import { useDownloadBarStyles } from './styles'
interface PlaylistPanelProps {
playlist: PlaylistInfo
selected: Set<number>
allSelected: boolean
onToggleAll: () => void
onSetAllKinds: (k: MediaKind) => void
onToggleEntry: (index: number, on: boolean) => void
effKind: (index: number) => MediaKind
onToggleItemKind: (index: number) => void
}
export function PlaylistPanel({
playlist,
selected,
allSelected,
onToggleAll,
onSetAllKinds,
onToggleEntry,
effKind,
onToggleItemKind
}: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles()
const text = useTextStyles()
return (
<div className={styles.plPanel}>
<div className={styles.plHeader}>
<AppsListRegular />
<Caption1 className={mergeClasses(styles.plHeaderGrow, text.title)}>
{playlist.title}
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
</Caption1>
<Caption1 className={text.muted}>
{selected.size} of {playlist.count} selected
</Caption1>
<Button size="small" appearance="subtle" onClick={onToggleAll}>
{allSelected ? 'Select none' : 'Select all'}
</Button>
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('audio')}>
All audio
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<div key={e.index} className={styles.plItemRow}>
<Checkbox
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
label={
<span className={styles.plItemLabel}>
<span className={text.truncate}>
{e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={text.muted}>
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
</Caption1>
)}
</span>
}
/>
<Hint
label={
effKind(e.index) === 'audio'
? 'Audio -- click for video'
: 'Video -- click for audio'
}
placement="top"
align="end"
>
<IconButton
size="sm"
style={{ flexShrink: 0 }}
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
onClick={() => onToggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
/>
</Hint>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,191 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { THUMB_LG } from '../../thumbSizes'
import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
export const useDownloadBarStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: SPACE.cozy,
// Page-level card: card padding + card radius, and the app's one "raised"
// elevation — the download bar floats above the queue (UI3/UI7/L100).
padding: SPACE.section,
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(RADIUS.card),
boxShadow: ELEVATION.raised
},
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: {
display: 'flex',
gap: '8px'
},
url: {
flexGrow: 1
},
// --- metadata preview card ---
preview: {
display: 'flex',
gap: '12px',
padding: '10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewThumb: {
flexShrink: 0,
width: `${THUMB_LG.w}px`,
height: `${THUMB_LG.h}px`,
objectFit: 'cover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground1,
// One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
},
previewBody: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '2px'
},
statusRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground3
},
errorRow: {
color: tokens.colorPaletteRedForeground1
},
controls: {
display: 'flex',
alignItems: 'flex-end',
gap: '16px',
flexWrap: 'wrap'
},
control: {
display: 'flex',
flexDirection: 'column',
gap: '4px'
},
quality: {
minWidth: '220px'
},
spacer: {
flexGrow: 1
},
// --- playlist selection ---
plPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
plHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
// Just the layout role; the truncation + weight come from the shared text.title
// (L114/L126), merged on at the call site.
plHeaderGrow: {
flexGrow: 1
},
plList: {
display: 'flex',
flexDirection: 'column',
maxHeight: '260px',
overflowY: 'auto',
paddingRight: '4px'
},
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0',
flexGrow: 1,
minWidth: 0
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-start'
},
optButtons: {
display: 'flex',
gap: '8px'
},
trimPanel: {
alignSelf: 'stretch',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// Native datetime-local input, themed to sit beside the Fluent controls.
// No explicit `colorScheme` here (UI32): it inherits the resolved in-app scheme
// set on the app root in App.tsx, so the native calendar popup follows the app's
// Light/Dark theme — consistent with the native <Select> — rather than the OS
// preference (which the former `'light dark'` value tied it to).
dtInput: {
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
padding: '6px 10px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1
},
// --- command preview & options panels ---
commandBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
commandPreviewPanel: {
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: tokens.colorNeutralForeground1
},
commandError: {
color: tokens.colorStatusDangerForeground1
},
optionsPanel: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
}
})
@@ -0,0 +1,606 @@
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import {
parseUrlShortcutContent,
type MediaInfo,
type FormatOption,
type PlaylistInfo,
type DownloadOptions,
DEFAULT_DOWNLOAD_OPTIONS,
type CommandPreviewResult
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads'
import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats'
import { useSettings } from '../../store/settings'
import { useNav } from '../../store/nav'
import {
useClipboardLink,
looksLikeUrl,
looksLikeChannelOrPlaylist,
firstUrlInText,
type SuggestionSource
} from '../../useClipboardLink'
import { logError } from '../../reportError'
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
function parseUrlFile(content: string): string | null {
const url = parseUrlShortcutContent(content)
return url && looksLikeUrl(url) ? url : null
}
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
// in LOCAL time -- used as the picker's `min` so a past time can't be chosen and
// then silently download immediately (L156/L57).
export function toLocalDatetimeValue(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Everything the DownloadBar render needs — the hook's public surface. */
export interface DownloadBarController {
// form state
url: string
kind: MediaKind
quality: string
// trim / schedule
showTrim: boolean
setShowTrim: Dispatch<SetStateAction<boolean>>
trim: string
showSchedule: boolean
setShowSchedule: Dispatch<SetStateAction<boolean>>
scheduleAt: string
setScheduleAt: Dispatch<SetStateAction<string>>
// drag / dup
dragActive: boolean
setDragActive: Dispatch<SetStateAction<boolean>>
dup: string | null
setDup: Dispatch<SetStateAction<string | null>>
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
dupInHistory: boolean
// advanced / preview
showAdvanced: boolean
setShowAdvanced: Dispatch<SetStateAction<boolean>>
downloadOptions: DownloadOptions
incognito: boolean
setIncognito: Dispatch<SetStateAction<boolean>>
commandPreview: CommandPreviewResult | null
previewLoading: boolean
// probe (single video)
info: MediaInfo | null
thumbFailed: string | null
setThumbFailed: Dispatch<SetStateAction<string | null>>
probing: boolean
probeError: string | null
usingFormats: boolean
selectedFormat: FormatOption | undefined
// playlist
playlist: PlaylistInfo | null
selected: Set<number>
allSelected: boolean
// suggestion banner
suggestion: string | null
suggestionSource: SuggestionSource
dismissSuggestion: () => void
// channel/playlist nudge → Library (UX3)
channelHint: boolean
openInLibrary: () => void
dismissChannelHint: () => void
// handlers
onDragEnter: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onUrlChange: (next: string) => void
onKindChange: (next: MediaKind) => void
onQualityChange: (v: string) => void
onFormatIdChange: (v: string) => void
onTrimChange: (v: string) => void
onDownloadOptionsChange: (next: DownloadOptions) => void
toggleCommandPreview: () => Promise<void>
fetchFormats: () => Promise<void>
paste: () => Promise<void>
acceptSuggestion: () => void
download: () => void
downloadAnyway: () => void
addPlaylist: () => void
toggleEntry: (index: number, on: boolean) => void
toggleAll: () => void
effKind: (index: number) => MediaKind
toggleItemKind: (index: number) => void
setAllKinds: (k: MediaKind) => void
}
/**
* All state and behaviour for the DownloadBar. Extracted from the component so
* the ~17 interdependent state hooks and their handlers live in one place and
* the component is render-only.
*/
export function useDownloadBar(): DownloadBarController {
const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const openLibraryWith = useNav((s) => s.openLibraryWith)
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 [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
const [trim, setTrim] = useState('')
// Optional schedule: a datetime-local value; a future time parks the download.
const [showSchedule, setShowSchedule] = useState(false)
const [scheduleAt, setScheduleAt] = useState('')
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
// Whether the duplicate matched a history entry (already downloaded) rather than
// a live queue item (L144) — the banner wording differs.
const [dupInHistory, setDupInHistory] = useState(false)
const confirmDup = useRef(false)
// Per-download options (M5/M6/UX1): advanced features.
const [showAdvanced, setShowAdvanced] = useState(false)
const [downloadOptions, setDownloadOptions] = useState<DownloadOptions>(DEFAULT_DOWNLOAD_OPTIONS)
const [incognito, setIncognito] = useState(false)
const [commandPreview, setCommandPreview] = useState<CommandPreviewResult | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
// Probe state for the single-video format picker.
const [info, setInfo] = useState<MediaInfo | null>(null)
const [thumbFailed, setThumbFailed] = useState<string | null>(null)
const [probing, setProbing] = useState(false)
const [probeError, setProbeError] = useState<string | null>(null)
const [formatId, setFormatId] = useState<string>('')
// Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
// dragleave fires every time the cursor crosses onto a child element, so toggling
// on leave made the dashed outline blink. Count enters vs leaves and only clear
// the highlight when the drag has actually left the whole card (depth back to 0).
const dragDepth = useRef(0)
function onDragEnter(e: React.DragEvent): void {
e.preventDefault()
dragDepth.current += 1
if (!dragActive) setDragActive(true)
}
function onDragOver(e: React.DragEvent): void {
// Required so the element is a valid drop target; the highlight is owned by
// onDragEnter/onDragLeave.
e.preventDefault()
}
function onDragLeave(): void {
dragDepth.current = Math.max(0, dragDepth.current - 1)
if (dragDepth.current === 0) setDragActive(false)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
dragDepth.current = 0
setDragActive(false)
const dt = e.dataTransfer
const fromText = firstUrlInText(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
if (fromText) {
onUrlChange(fromText)
return
}
// A dropped Windows .url Internet Shortcut -- read its URL= line.
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
if (file) {
file
.text()
.then((content) => {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(logError('dropped .url file read'))
}
}
// When settings load or change, update kind/quality -- but only while the bar
// is idle (URL empty) so an in-progress setup isn't unexpectedly reset.
useEffect(() => {
if (!settingsLoaded || url.trim()) return
setKind(defaultKind)
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
// Clipboard auto-detect and links handed to AeroFetch from outside (the
// aerofetch:// protocol or a "Send to" .url file) share one suggestion banner,
// driven by the same hook the library's add-source field uses. An external link
// always takes priority over a clipboard guess -- it's a direct request.
const {
suggestion,
source: suggestionSource,
accept: acceptLink,
dismiss: dismissSuggestion,
offer: offerLink
} = useClipboardLink(url)
useEffect(
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
[offerLink]
)
function acceptSuggestion(): void {
const link = acceptLink()
if (link) onUrlChange(link)
}
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])
: undefined
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
// but only while the bar is still idle for it (nothing probed/resolved yet) and
// the user hasn't waved it away (UX3).
const channelHint =
looksLikeChannelOrPlaylist(url) &&
!channelHintDismissed &&
info === null &&
playlist === null &&
probeError === null
function clearProbe(): void {
setInfo(null)
setProbeError(null)
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
}
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
// probe and drop the one-shot overrides (trim / schedule / advanced / incognito /
// per-download options) so the next download starts from the global defaults.
// Shared by download() and addPlaylist() so a playlist add resets the same state
// a single download does (L132 — addPlaylist previously left trim/schedule open).
function resetForm(): void {
setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
setShowAdvanced(false)
setCommandPreview(null)
setIncognito(false)
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
clearProbe()
}
// Fetch (or toggle off) the exact yt-dlp command line for the current form
// state. The options mirror what download() sends so the preview matches the
// command that will actually run -- including a probe-selected format and any
// trim spec (M5).
async function toggleCommandPreview(): Promise<void> {
if (commandPreview) {
setCommandPreview(null)
return
}
const trimmed = url.trim()
if (!trimmed) return
setPreviewLoading(true)
try {
const result = await window.api.previewCommand({
id: 'preview',
url: trimmed,
kind: usingFormats ? 'video' : kind,
quality: usingFormats && selectedFormat ? selectedFormat.label : quality,
formatId: usingFormats ? selectedFormat?.id : undefined,
formatHasAudio: usingFormats ? selectedFormat?.hasAudio : undefined,
options: downloadOptions,
trim: trim.trim() || undefined
})
setCommandPreview(result)
} catch (e) {
setCommandPreview({ ok: false, error: (e as Error).message })
} finally {
setPreviewLoading(false)
}
}
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info -- or a duplicate warning -- is stale once the URL changes.
if (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
setCommandPreview(null)
confirmDup.current = false
// A new URL gets a fresh chance to nudge toward the Library.
setChannelHintDismissed(false)
}
function onKindChange(next: MediaKind): void {
setKind(next)
setQuality(QUALITY_OPTIONS[next][0])
// The command changes with kind/quality, so any shown preview is now stale.
setCommandPreview(null)
}
// Any change to quality / format / trim / options invalidates a shown preview.
function onQualityChange(v: string): void {
setQuality(v)
setCommandPreview(null)
}
function onFormatIdChange(v: string): void {
setFormatId(v)
setCommandPreview(null)
}
function onTrimChange(v: string): void {
setTrim(v)
setCommandPreview(null)
}
function onDownloadOptionsChange(next: DownloadOptions): void {
setDownloadOptions(next)
setCommandPreview(null)
}
async function fetchFormats(): Promise<void> {
const trimmed = url.trim()
if (!trimmed || probing) return
setProbing(true)
setProbeError(null)
setInfo(null)
setPlaylist(null)
// Probing may switch the URL to the format-picker path, changing the command.
setCommandPreview(null)
try {
const res = await window.api.probe(trimmed)
if (res.ok && res.kind === 'playlist' && res.playlist) {
setPlaylist(res.playlist)
// Pre-select every entry; the user can trim the list.
setSelected(new Set(res.playlist.entries.map((e) => e.index)))
} else if (res.ok && res.info) {
setInfo(res.info)
setFormatId(res.info.formats[0]?.id ?? '')
} else {
setProbeError(res.error ?? 'Could not fetch video info.')
}
} catch (e) {
setProbeError(String(e))
} finally {
setProbing(false)
}
}
async function paste(): Promise<void> {
try {
const text = (await window.api?.readClipboard?.()) ?? ''
if (text) onUrlChange(text.trim())
} catch {
/* ignore in preview */
}
}
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
// add field with it, and clear the bar (the URL now lives over there) (UX3).
function openInLibrary(): void {
const trimmed = url.trim()
if (!trimmed) return
openLibraryWith(trimmed)
setUrl('')
// Clear the URL-specific command preview too, so no stale command lingers over
// the now-empty bar (the same invariant onUrlChange keeps).
setCommandPreview(null)
clearProbe()
}
function dismissChannelHint(): void {
setChannelHintDismissed(true)
}
// Selection helpers for the playlist panel.
const allSelected = playlist !== null && selected.size === playlist.entries.length
function toggleEntry(index: number, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(index)
else next.delete(index)
return next
})
}
function toggleAll(): void {
if (!playlist) return
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
// Duplicate guard: if this URL is already in the queue (and the user hasn't
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
// Canceled/failed items don't count -- re-adding those is a legitimate retry.
if (!confirmDup.current) {
const existing = useDownloads
.getState()
.items.find(
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
// Use the URL as fallback if the title is still the placeholder generated
// by titleFromUrl() before metadata loads (L76).
const isPlaceholder =
/video \(\w+\)$/.test(existing.title) ||
existing.title.endsWith(' download') ||
existing.title === 'New download'
setDup(isPlaceholder ? existing.url : existing.title)
setDupInHistory(false)
return
}
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
// re-download is a deliberate choice, not an accidental duplicate.
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
if (downloaded) {
setDup(downloaded.title || downloaded.url)
setDupInHistory(true)
return
}
}
confirmDup.current = false
setDup(null)
const meta = info
? {
title: info.title,
channel: info.channel,
durationLabel: info.durationLabel,
thumbnail: info.thumbnail
}
: {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta,
trim: trimSpec,
scheduledFor,
options: downloadOptions,
incognito,
format: {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
}
})
} else {
addFromUrl(trimmed, kind, quality, {
...meta,
trim: trimSpec,
scheduledFor,
options: downloadOptions,
incognito
})
}
resetForm()
}
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
// Each entry uses its own kind override; quality falls back to that kind's
// default unless the entry matches the bar's current kind/quality.
addMany(
chosen.map((e) => {
const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
})
)
resetForm()
}
return {
// form state
url,
kind,
quality,
// trim / schedule
showTrim,
setShowTrim,
trim,
showSchedule,
setShowSchedule,
scheduleAt,
setScheduleAt,
// drag / dup
dupInHistory,
dragActive,
setDragActive,
dup,
setDup,
// advanced / preview
showAdvanced,
setShowAdvanced,
downloadOptions,
incognito,
setIncognito,
commandPreview,
previewLoading,
// probe (single video)
info,
thumbFailed,
setThumbFailed,
probing,
probeError,
usingFormats,
selectedFormat,
// playlist
playlist,
selected,
allSelected,
// suggestion banner
suggestion,
suggestionSource,
dismissSuggestion,
// channel/playlist nudge → Library (UX3)
channelHint,
openInLibrary,
dismissChannelHint,
// handlers
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
onUrlChange,
onKindChange,
onQualityChange,
onFormatIdChange,
onTrimChange,
onDownloadOptionsChange,
toggleCommandPreview,
fetchFormats,
paste,
acceptSuggestion,
download,
downloadAnyway,
addPlaylist,
toggleEntry,
toggleAll,
effKind,
toggleItemKind,
setAllKinds
}
}
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
import {
Field,
Switch,
Button,
Card,
Subtitle2,
Caption1,
Text,
Spinner
} from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import {
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult
} from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles'
const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' },
{ value: 'nightly', label: 'Nightly' }
]
export function AboutCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update)
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
useEffect(() => {
// Show the current yt-dlp version without a manual click, and reflect a
// background auto-update (which may run on launch) live in this panel.
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkVersion(): Promise<void> {
setChecking(true)
setVersion(null)
try {
setVersion(await window.api.getYtdlpVersion())
} catch (e) {
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setChecking(false)
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
} catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setUpdating(false)
}
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
</Caption1>
<div className={styles.folderRow}>
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
<Button size="small" onClick={() => update({ hasCompletedOnboarding: false })}>
Show welcome tips again
</Button>
</div>
<Field
label="Keep yt-dlp updated automatically"
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
>
<Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
/>
</Field>
{version?.ok && <Text className={styles.mono}>yt-dlp {version.version}</Text>}
{version && !version.ok && <Caption1 className={errText.errorPre}>{version.error}</Caption1>}
{ffmpeg && (
<div className={styles.folderRow}>
<div>
<Text className={styles.monoBlock}>ffmpeg {ffmpeg.ffmpeg ?? 'not found'}</Text>
<Text className={styles.monoBlock}>ffprobe {ffmpeg.ffprobe ?? 'not found'}</Text>
</div>
<Button
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() =>
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
}
aria-label="Re-check ffmpeg versions"
/>
</div>
)}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
</Caption1>
)}
<div className={styles.folderRow}>
<Button
icon={checking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={checkVersion}
disabled={checking}
>
Check yt-dlp version
</Button>
</div>
<Field
label="Update channel"
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
>
<Select
value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/>
</Field>
<div className={styles.folderRow}>
<Button
icon={updating ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={runUpdate}
disabled={updating}
>
{updating ? 'Updating…' : 'Update yt-dlp'}
</Button>
</div>
{updateResult?.ok && (
<Text className={styles.monoPre}>
{updateResult.output || 'yt-dlp is already up to date.'}
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
)}
</Card>
)
}
@@ -0,0 +1,110 @@
import { useRef } from 'react'
import { Field, Button, Card, Subtitle2, Caption1, mergeClasses } from '@fluentui/react-components'
import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
import { type ThemeMode, type AccentColor } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useSystemTheme } from '../../store/systemTheme'
import { Select } from '../Select'
import { ACCENT_OPTIONS } from '../../theme'
import { useFocusStyles } from '../ui/focusRing'
import { useSettingsStyles } from './settingsStyles'
const THEME_MODE_OPTIONS = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' }
]
export function AppearanceCard(): React.JSX.Element {
const styles = useSettingsStyles()
const focus = useFocusStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const update = useSettings((s) => s.update)
// Roving-tabindex arrow-key navigation for the accent radiogroup (L155), so the
// single-select swatches behave like the Theme radio pattern rather than a row
// of plain buttons.
const swatchRefs = useRef<(HTMLButtonElement | null)[]>([])
function onSwatchKey(e: React.KeyboardEvent, index: number): void {
const n = ACCENT_OPTIONS.length
let next = -1
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % n
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (index - 1 + n) % n
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = n - 1
else return
const opt = ACCENT_OPTIONS[next]
if (!opt) return
e.preventDefault()
update({ accentColor: opt.value as AccentColor })
swatchRefs.current[next]?.focus()
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Appearance</Subtitle2>
</div>
<Field label="Theme">
<Select
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
/>
</Field>
<Field label="Accent color">
<div className={styles.swatchRow} role="radiogroup" aria-label="Accent color">
{ACCENT_OPTIONS.map((opt, i) => {
const selected = accentColor === opt.value
return (
<button
key={opt.value}
ref={(el) => {
swatchRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={selected}
// Roving tabindex: only the selected swatch is a tab stop; arrows
// move within the group (L155).
tabIndex={selected ? 0 : -1}
className={mergeClasses(
styles.swatch,
selected && styles.swatchActive,
focus.focusRing
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
onKeyDown={(e) => onSwatchKey(e, i)}
aria-label={opt.label}
title={opt.label}
/>
)
})}
</div>
</Field>
<Caption1 className={styles.hint}>
{highContrast
? 'A Windows high-contrast theme is active -- AeroFetch follows your system colors.'
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
</Caption1>
{highContrast && (
<div className={styles.folderRow}>
<Button
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
Open accessibility settings
</Button>
</div>
)}
</Card>
)
}
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
import { type BackupExportResult, type BackupImportResult } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles'
export function BackupCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
try {
setExportResult(await window.api.exportBackup())
} catch (e) {
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setExporting(false)
}
}
async function importBackup(): Promise<void> {
setImporting(true)
setImportResult(null)
try {
const result = await window.api.importBackup()
setImportResult(result)
if (result.ok) {
// Settings + templates changed underneath the stores -- reload both.
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t })
}
} catch (e) {
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setImporting(false)
}
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on another
machine. Does not include download history or credentials (proxy, API tokens -- re-enter
those after import).
</Caption1>
<div className={styles.folderRow}>
<Button icon={<DocumentArrowDownRegular />} onClick={exportBackup} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export backup…'}
</Button>
<Button icon={<DocumentArrowUpRegular />} onClick={importBackup} disabled={importing}>
{importing ? 'Importing…' : 'Import backup…'}
</Button>
</div>
{exportResult?.ok && exportResult.path && (
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
)}
{exportResult && !exportResult.ok && exportResult.error && (
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 className={errText.error}>{importResult.error}</Caption1>
)}
</Card>
)
}
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react'
import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { CookiesRegular } from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type CookieSource,
type CookieBrowser,
type CookiesStatus
} from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles'
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'browser', label: "From a browser's cookie store" },
{ value: 'login', label: 'Sign-in window (built into AeroFetch)' }
]
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
value: b,
label: b.charAt(0).toUpperCase() + b.slice(1)
}))
export function CookiesCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const update = useSettings((s) => s.update)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything -- don't imply success (L50).
setLoginError('No cookies were captured -- did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
setLoginError(result.error ?? 'Sign-in failed.')
}
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
setSigningIn(false)
}
}
async function clearSavedCookies(): Promise<void> {
await window.api.cookiesClear()
setCookiesStatus({ exists: false })
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
session. Supply cookies so yt-dlp can act like one.
</Caption1>
<Field label="Cookie source">
<Select
value={cookieSource}
options={COOKIE_SOURCE_OPTIONS}
onChange={(v) => update({ cookieSource: v as CookieSource })}
/>
</Field>
{cookieSource === 'browser' && (
<Field
label="Browser"
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
>
<Select
value={cookiesBrowser}
options={COOKIE_BROWSER_OPTIONS}
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
/>
</Field>
)}
{cookieSource === 'login' && (
<>
<Field
label="Site to sign in to"
hint="Opens a sign-in window. Log in, then close the window -- your cookies are saved automatically."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={loginUrl}
placeholder="https://www.youtube.com"
onChange={(_, d) => setLoginUrl(d.value)}
/>
<Button appearance="primary" onClick={signIn} disabled={signingIn}>
{signingIn ? 'Signing in…' : 'Sign in…'}
</Button>
</div>
</Field>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{cookiesStatus?.exists
? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.`
: 'Not signed in yet.'}
</Caption1>
{cookiesStatus?.exists && (
<Button size="small" onClick={clearSavedCookies}>
Clear saved cookies
</Button>
)}
</div>
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
</>
)}
</Card>
)
}
@@ -0,0 +1,54 @@
import { Field, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { CodeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
import { Select } from '../Select'
import { TemplateManager } from '../TemplateManager'
import { useSettingsStyles } from './settingsStyles'
export function CustomCommandsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Custom commands</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other option,
so a template flag can override a setting above it.
</Caption1>
<Field
label="Run custom command"
hint="Applies the default template below to every new download (still overridable per download)."
>
<Switch
checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
/>
</Field>
{customCommandEnabled && (
<Field label="Default template">
<Select
value={defaultTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })}
/>
</Field>
)}
<TemplateManager />
</Card>
)
}
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../ui/errorText'
import { EmptyState } from '../ui/EmptyState'
import { useSettingsStyles } from './settingsStyles'
export function DiagnosticsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const errorEntries = useErrorLog((s) => s.entries)
const clearErrorLog = useErrorLog((s) => s.clear)
const [confirmClearLog, setConfirmClearLog] = useState(false)
function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always
// non-empty here -- no need for an unreachable empty-report fallback (L159).
const report = errorEntries
.map((e) =>
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
)
.join('\n\n---\n\n')
navigator.clipboard.writeText(report).catch(() => {})
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Diagnostics</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Failed downloads are logged here even after you clear the queue, so you can copy the details
into a bug report.
</Caption1>
<div className={styles.folderRow}>
<Button
icon={<CopyRegular />}
onClick={copyErrorReport}
disabled={errorEntries.length === 0}
>
Copy full report
</Button>
{confirmClearLog ? (
<>
<Caption1>
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
</Caption1>
<Button
icon={<DeleteRegular />}
appearance="primary"
onClick={() => {
clearErrorLog()
setConfirmClearLog(false)
}}
>
Clear log
</Button>
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
Cancel
</Button>
</>
) : (
<Button
icon={<DeleteRegular />}
onClick={() => setConfirmClearLog(true)}
disabled={errorEntries.length === 0}
>
Clear log
</Button>
)}
</div>
{errorEntries.length === 0 ? (
<EmptyState compact message="No errors yet." />
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
{errorEntries.length > 20 && (
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
)}
</div>
)}
</Card>
)
}
@@ -0,0 +1,180 @@
import {
Field,
Input,
SpinButton,
Switch,
Button,
Card,
Subtitle2
} from '@fluentui/react-components'
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
import { type MediaKind } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useDownloads } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { Select } from '../Select'
import { SegmentedControl } from '../ui/SegmentedControl'
import { useSettingsStyles } from './settingsStyles'
export function DownloadsCard(): React.JSX.Element {
const styles = useSettingsStyles()
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)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const launchAtStartup = useSettings((s) => s.launchAtStartup)
const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
function onKindSelect(kind: MediaKind): void {
update({ defaultKind: kind })
}
function onQualitySelect(quality: string): void {
update(
defaultKind === 'audio' ? { defaultAudioQuality: quality } : { defaultVideoQuality: quality }
)
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Downloads</Subtitle2>
</div>
<Field
label="Video folder"
hint="Where video downloads are saved. Leave blank to use Documents\Video."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={videoDir}
placeholder="Documents\Video (default)"
contentBefore={<FolderRegular />}
onChange={(_, d) => update({ videoDir: d.value })}
/>
<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
className={styles.folderInput}
value={audioDir}
placeholder="Documents\Audio (default)"
contentBefore={<FolderRegular />}
onChange={(_, d) => update({ audioDir: d.value })}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Browse
</Button>
{audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field label="Default format">
<div className={styles.formatRow}>
<SegmentedControl<MediaKind>
value={defaultKind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={onKindSelect}
ariaLabel="Default download type"
/>
<Select
className={styles.formatQuality}
value={formatQuality}
options={QUALITY_OPTIONS[defaultKind].map((q) => ({ value: q, label: q }))}
onChange={onQualitySelect}
/>
</div>
</Field>
<Field
label="Maximum simultaneous downloads"
hint="How many downloads run at once. 2-3 is a good balance."
>
<SpinButton
value={maxConcurrent}
min={1}
max={5}
onChange={(_, d) => {
const v = d.value ?? Number(d.displayValue)
if (typeof v === 'number' && Number.isFinite(v)) {
update({ maxConcurrent: Math.min(5, Math.max(1, Math.round(v))) })
// Raising the cap should start queued items right away.
useDownloads.getState().pump()
}
}}
/>
</Field>
<Field
label="Detect links from clipboard"
hint="When AeroFetch gains focus, offer a video link you've just copied."
>
<Switch
checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })}
/>
</Field>
<Field
label="Notify when downloads finish"
hint="Shows a native Windows notification when a download completes or fails."
>
<Switch
checked={notifyOnComplete}
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
/>
</Field>
<Field
label="Keep running in the tray"
hint="Closing the window minimizes to the system tray instead of quitting, so downloads and watched-channel syncing keep running."
>
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
/>
</Field>
<Field
label="Start with Windows"
hint="Launch AeroFetch automatically when you sign in -- useful with auto-download so watched channels stay current in the background."
>
<Switch
checked={launchAtStartup}
onChange={(_, d) => update({ launchAtStartup: d.checked })}
/>
</Field>
</Card>
)
}
@@ -0,0 +1,53 @@
import { Field, Input, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useSettingsStyles } from './settingsStyles'
export function FilenamesCard(): React.JSX.Element {
const styles = useSettingsStyles()
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const update = useSettings((s) => s.update)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Filenames</Subtitle2>
</div>
<Field
label="Filename template"
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
>
<Input
value={filenameTemplate}
onChange={(_, d) => update({ filenameTemplate: d.value })}
/>
</Field>
<Caption1 className={styles.hint}>
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
</Caption1>
<Field
label="Restrict filenames"
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
>
<Switch
checked={restrictFilenames}
onChange={(_, d) => update({ restrictFilenames: d.checked })}
/>
</Field>
<Field
label="Skip already-downloaded videos"
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
>
<Switch
checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })}
/>
</Field>
</Card>
)
}
@@ -0,0 +1,118 @@
import { useState } from 'react'
import { Field, Input, Switch, Button, Card, Subtitle2, Spinner } from '@fluentui/react-components'
import { GlobeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useSettingsStyles } from './settingsStyles'
export function NetworkCard(): React.JSX.Element {
const styles = useSettingsStyles()
const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c)
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update)
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Network</Subtitle2>
</div>
<Field
label="Proxy"
hint="HTTP/HTTPS/SOCKS proxy URL, e.g. socks5://127.0.0.1:1080. Leave blank to use the system default."
>
<Input
type="password"
value={proxy}
placeholder="socks5://127.0.0.1:1080"
onChange={(_, d) => update({ proxy: d.value })}
/>
</Field>
<Field
label="Rate limit"
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
>
<Input
value={rateLimit}
placeholder="2M"
onChange={(_, d) => update({ rateLimit: d.value })}
/>
</Field>
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
>
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
</Field>
<Field
label="YouTube client (advanced)"
hint="Override how AeroFetch identifies itself to YouTube when downloads start failing. Try web_safari, tv, or mweb. Leave blank for the automatic default."
>
<datalist id="yt-client-list">
{['web', 'web_safari', 'web_embedded', 'mweb', 'tv', 'ios', 'android'].map((c) => (
<option key={c} value={c} />
))}
</datalist>
<Input
value={youtubePlayerClient}
placeholder="web_safari"
list="yt-client-list"
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
/>
</Field>
<Field
label="YouTube PO Token (advanced)"
hint={
potHint ??
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
}
>
<div className={styles.folderRow}>
<Input
type="password"
style={{ flexGrow: 1 }}
value={youtubePoToken}
placeholder="Optional -- paste a token"
onChange={(_, d) => {
update({ youtubePoToken: d.value })
setPotHint(null)
}}
/>
<Button
size="small"
disabled={mintingPot}
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
onClick={async () => {
setMintingPot(true)
setPotHint(null)
try {
const token = await window.api.mintPoToken()
if (token) {
setPotHint('Token saved.')
} else {
setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
}
} catch {
setPotHint('Failed to open YouTube window.')
} finally {
setMintingPot(false)
}
}}
>
{mintingPot ? 'Fetching…' : 'Fetch'}
</Button>
</div>
</Field>
</Card>
)
}
@@ -0,0 +1,30 @@
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { OptionsRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { DownloadOptionsForm } from '../DownloadOptionsForm'
import { useSettingsStyles } from './settingsStyles'
export function PostProcessingCard(): React.JSX.Element {
const styles = useSettingsStyles()
const defaultKind = useSettings((s) => s.defaultKind)
const downloadOptions = useSettings((s) => s.downloadOptions)
const update = useSettings((s) => s.update)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Format &amp; post-processing</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
</Caption1>
<DownloadOptionsForm
value={downloadOptions}
kind={defaultKind}
onChange={(o) => update({ downloadOptions: o })}
/>
</Card>
)
}
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react'
import {
Field,
Input,
Button,
Card,
Subtitle2,
Caption1,
Text,
Spinner,
ProgressBar,
tokens
} from '@fluentui/react-components'
import { ArrowSyncRegular, ArrowDownloadRegular, OpenRegular } from '@fluentui/react-icons'
import { type AppUpdateInfo } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles'
export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.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)
useEffect(() => {
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
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)
}
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">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={appChecking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={checkAppUpdate}
disabled={appChecking || appDownloading}
>
{appChecking ? 'Checking…' : 'Check for updates'}
</Button>
</div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field
label="Update access token"
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
)}
{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={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
>
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} aria-label="App update download progress" />
)}
</>
)}
{appUpd?.ok && !appUpd.available && (
<Text>You&apos;re up to date -- v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
</Card>
)
}
@@ -0,0 +1,117 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { SPACE, RADIUS, ICON } from '../ui/tokens'
// Shared styles for the Settings cards. Each card renders a single <Card> so it
// stays one direct child of SettingsView's root -- the search filter toggles
// `display` per child, so the one-DOM-node-per-card shape must be preserved.
export const useSettingsStyles = makeStyles({
card: {
display: 'flex',
flexDirection: 'column',
// Page-level card: card padding + card radius (UI3/UI7).
gap: SPACE.section,
padding: SPACE.section,
...shorthands.borderRadius(RADIUS.card)
},
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',
gap: '10px'
},
sectionIcon: {
// Section-header glyph, next to each card's <Subtitle2> title (UI12 section tier).
fontSize: `${ICON.section}px`,
color: tokens.colorCompoundBrandForeground1
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
// The "Default format" control: a Video/Audio segmented toggle beside a quality
// dropdown, mirroring the DownloadBar so both read as one mental model (L102).
formatRow: {
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap'
},
formatQuality: {
minWidth: '200px'
},
hint: {
color: tokens.colorNeutralForeground3
},
// Monospace version/output text (About card). Replaces the repeated inline
// `style={{ fontFamily: fontFamilyMonospace }}` (L5).
mono: {
fontFamily: tokens.fontFamilyMonospace
},
monoBlock: {
fontFamily: tokens.fontFamilyMonospace,
display: 'block'
},
monoPre: {
fontFamily: tokens.fontFamilyMonospace,
whiteSpace: 'pre-wrap'
},
swatchRow: {
display: 'flex',
gap: '10px'
},
swatch: {
boxSizing: 'border-box',
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius(tokens.borderRadiusCircular),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer',
// The swatch *is* a color preview, so keep the accent color under Windows
// High Contrast / forced-colors (W18); otherwise every swatch flattens to one
// system color and the accents become indistinguishable.
forcedColorAdjust: 'none'
},
swatchActive: {
...shorthands.borderColor(tokens.colorNeutralForeground1)
},
errorList: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
errorRow: {
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
errorRowHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorRowTitle: {
flexGrow: 1,
minWidth: 0,
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
+63
View File
@@ -0,0 +1,63 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { RADIUS } from './tokens'
import { useTextStyles } from './text'
/**
* One inline notification bar (UI19). The DownloadBar's copied-link suggestion,
* its channel/playlist nudge, its duplicate warning, and the Library's copied-link
* suggestion had each hand-rolled the same tinted row (leading icon + flex-grow
* text + trailing actions). This is that row, once: a leading `icon`, `children`
* content that truncates by default (`wrap` to let it flow onto multiple lines),
* and a trailing `actions` slot.
*
* `tone` picks the semantic tint `brand` for suggestions/nudges, `warning` for
* the duplicate caution matching the former per-component colors exactly.
*/
export type BannerTone = 'brand' | 'warning'
const useStyles = makeStyles({
root: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
...shorthands.borderRadius(RADIUS.surface)
},
brand: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
warning: {
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1
},
content: {
flexGrow: 1,
minWidth: 0
}
})
export function Banner({
icon,
tone = 'brand',
wrap = false,
children,
actions
}: {
icon?: React.ReactNode
tone?: BannerTone
/** Let the content wrap onto multiple lines instead of truncating (the channel nudge). */
wrap?: boolean
children: React.ReactNode
actions?: React.ReactNode
}): React.JSX.Element {
const styles = useStyles()
const text = useTextStyles()
return (
<div className={mergeClasses(styles.root, styles[tone])}>
{icon}
<div className={mergeClasses(styles.content, !wrap && text.truncate)}>{children}</div>
{actions}
</div>
)
}
@@ -0,0 +1,57 @@
import { Body1, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { SPACE } from './tokens'
/**
* One shared empty-state block (L116/L117). The screens had drifted into three
* shapes Downloads (icon + line), History (colored badge + line + sub-hint),
* Library (icon + line, no sub-hint) and the Terminal / Diagnostics "empty"
* text wasn't a centered block at all (inline text). This centers them all on one
* structure: an optional focal `icon` (a hero glyph or a colored badge), a `Body1`
* message at normal foreground, and an optional muted `hint` line.
*
* `compact` swaps the tall screen-level padding (56px) for a smaller inset (32px),
* for placeholders that sit inside an already-populated screen (the Terminal log,
* the Diagnostics list) rather than filling an empty one.
*/
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: SPACE.tight,
padding: '56px 16px',
// The container is muted, so a hero glyph and the hint line read as secondary;
// the message overrides back to the normal foreground so it stays legible.
color: tokens.colorNeutralForeground3,
textAlign: 'center'
},
compact: {
padding: '32px 16px'
},
message: {
color: tokens.colorNeutralForeground1
}
})
export function EmptyState({
icon,
message,
hint,
compact = false,
className
}: {
icon?: React.ReactNode
message: React.ReactNode
hint?: React.ReactNode
compact?: boolean
className?: string
}): React.JSX.Element {
const styles = useStyles()
return (
<div className={mergeClasses(styles.root, compact && styles.compact, className)}>
{icon}
<Body1 className={styles.message}>{message}</Body1>
{hint && <Caption1>{hint}</Caption1>}
</div>
)
}
@@ -0,0 +1,59 @@
import { forwardRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
import { ICON } from './tokens'
const useStyles = makeStyles({
btn: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
borderRadius: tokens.borderRadiusMedium,
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
},
':disabled': {
cursor: 'not-allowed',
color: tokens.colorNeutralForegroundDisabled,
backgroundColor: 'transparent'
}
},
md: { width: '32px', height: '32px', fontSize: `${ICON.control}px` },
sm: { width: '28px', height: '28px', fontSize: `${ICON.inline}px` }
})
type IconButtonProps = {
icon: React.JSX.Element
size?: 'sm' | 'md'
} & React.ButtonHTMLAttributes<HTMLButtonElement>
/**
* The recurring icon-only ghost button (UI15) one implementation for the
* sidebar's collapse/theme toggles and the playlist row's video/audio switch,
* which were separate hand-rolled buttons with the same look. Carries the shared
* focus ring (UI29). Extra props (onClick, aria-label, aria-pressed, disabled, )
* pass straight through to the underlying `<button>`.
*/
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ icon, size = 'md', className, type = 'button', ...rest },
ref
) {
const styles = useStyles()
const focus = useFocusStyles()
return (
<button
ref={ref}
type={type}
className={mergeClasses(styles.btn, styles[size], focus.focusRing, className)}
{...rest}
>
{icon}
</button>
)
})
@@ -0,0 +1,50 @@
import { Button, Caption1 } from '@fluentui/react-components'
import { LinkRegular, DismissRegular } from '@fluentui/react-icons'
import { Banner } from './Banner'
/**
* The "use this copied/received link?" banner (UI19), shared by the DownloadBar
* and the Library add-source field, which had identical markup + CSS. Renders the
* link (with an optional lead-in like "Link received: ") and Use / Dismiss actions
* on the shared {@link Banner}.
*/
export function LinkSuggestion({
prefix,
link,
onAccept,
onDismiss,
dismissLabel
}: {
/** Short lead-in before the link, e.g. "Use copied link? " or "Link received: ". */
prefix: string
link: string
onAccept: () => void
onDismiss: () => void
/** Accessible name for the dismiss button — say what's being dismissed (L153). */
dismissLabel: string
}): React.JSX.Element {
return (
<Banner
icon={<LinkRegular />}
actions={
<>
<Button size="small" appearance="primary" onClick={onAccept}>
Use
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={onDismiss}
aria-label={dismissLabel}
/>
</>
}
>
<Caption1>
{prefix}
{link}
</Caption1>
</Banner>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
/**
* One shared content max-width for every screen (UI1).
*
* Settings used to be a 640px column while the list screens (Downloads, Library,
* History, Terminal) were full-width, so on a wide window Settings read as an odd
* narrow strip beside edge-to-edge siblings. This caps them all at the same
* reading width and centers, so they line up. On typical window sizes the content
* is already under the cap, so the list screens are visually unchanged only the
* wide-window upper bound is now shared.
*
* Merge `screen.width` onto each screen's existing root with `mergeClasses`.
*/
export const useScreenStyles = makeStyles({
width: {
width: '100%',
maxWidth: '1200px',
marginLeft: 'auto',
marginRight: 'auto'
}
})
const useStyles = makeStyles({
header: {
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
flexWrap: 'wrap'
},
titleBlock: {
display: 'flex',
flexDirection: 'column',
gap: '2px',
minWidth: 0,
flexGrow: 1
},
description: {
color: tokens.colorNeutralForeground3
},
actions: {
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0
}
})
/**
* The consistent screen header block (UI9/UI10): one page-title style on every
* screen, with an optional one-line description and an optional right-aligned
* action slot. Previously each screen rolled its own (Subtitle2 / Title2 / none),
* and only Library had a description.
*/
export function ScreenHeader({
title,
description,
actions
}: {
title: string
description?: React.ReactNode
actions?: React.ReactNode
}): React.JSX.Element {
const styles = useStyles()
return (
<div className={styles.header}>
<div className={styles.titleBlock}>
<Subtitle2 as="h1">{title}</Subtitle2>
{description && <Caption1 className={styles.description}>{description}</Caption1>}
</div>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
)
}
@@ -0,0 +1,153 @@
import { useRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
import { ICON } from './tokens'
export interface SegmentOption<T extends string> {
value: T
label: string
/** optional leading icon (used by the sidebar theme switch) */
icon?: React.JSX.Element
}
const useStyles = makeStyles({
group: {
display: 'inline-flex',
width: 'fit-content',
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
overflow: 'hidden'
},
// Full-width variant: segments split the available width (the sidebar style).
fitted: {
display: 'flex',
width: '100%'
},
segment: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
padding: '7px 16px',
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
segmentFitted: {
flexGrow: 1,
flexBasis: 0,
padding: '7px 4px',
fontSize: tokens.fontSizeBase200
},
segmentActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
},
// Under Windows High Contrast the brand background is flattened to the system
// canvas, so the checked segment would be indistinguishable from the others
// (W18). Paint it with the system Highlight pair so the selection stays visible.
'@media (forced-colors: active)': {
backgroundColor: 'Highlight',
color: 'HighlightText',
forcedColorAdjust: 'none'
}
},
icon: {
display: 'flex',
fontSize: `${ICON.inline}px`,
flexShrink: 0
}
})
/**
* One shared segmented control (UI14) replacing the two hand-rolled versions
* the DownloadBar's Video/Audio kind toggle and the Sidebar's Light/Dark/Auto
* theme switch. Renders an ARIA radiogroup with roving-tabindex arrow-key
* navigation (UI30) and the shared focus ring (UI29). `fitted` makes the segments
* split the full width (sidebar); the default is fit-content (download bar).
*
* Active treatment is solid brand the app's one rule for *toggles* (a compact
* set where the chosen option should read as pressed). This is deliberately
* distinct from the brand-*tint* used for *selection within a list* (the Sidebar
* nav item, the CommandPalette active option): one active treatment per control
* class (UI20).
*/
export function SegmentedControl<T extends string>({
value,
options,
onChange,
ariaLabel,
fitted = false
}: {
value: T
options: SegmentOption<T>[]
onChange: (value: T) => void
ariaLabel: string
fitted?: boolean
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const refs = useRef<(HTMLButtonElement | null)[]>([])
function onKeyDown(e: React.KeyboardEvent, index: number): void {
let next = -1
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % options.length
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
next = (index - 1 + options.length) % options.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = options.length - 1
else return
const opt = options[next]
if (!opt) return
e.preventDefault()
onChange(opt.value)
refs.current[next]?.focus()
}
return (
<div
className={mergeClasses(styles.group, fitted && styles.fitted)}
role="radiogroup"
aria-label={ariaLabel}
>
{options.map((opt, i) => {
const on = opt.value === value
return (
<button
key={opt.value}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={on}
// Roving tabindex: only the selected segment is a tab stop; arrow keys
// move within the group, as a radiogroup is expected to behave.
tabIndex={on ? 0 : -1}
className={mergeClasses(
styles.segment,
fitted && styles.segmentFitted,
on && styles.segmentActive,
focus.focusRing
)}
onClick={() => onChange(opt.value)}
onKeyDown={(e) => onKeyDown(e, i)}
>
{opt.icon && <span className={styles.icon}>{opt.icon}</span>}
{opt.label}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,35 @@
import { Badge } from '@fluentui/react-components'
import type { DownloadStatus } from '../../store/downloads'
/** The item-download status shown as a chip the queue's live status plus the
* library's 'pending' (catalogued but not yet downloaded). */
export type ChipStatus = DownloadStatus | 'pending'
type ChipColor = 'brand' | 'success' | 'danger' | 'warning' | 'subtle'
/**
* One label + color per status (UI18 / M8) the single source of truth that
* replaces QueueItem's Fluent `Badge` map and LibraryView's custom color `pill`
* spans, so the same status concept looks and reads identically on both screens
* (e.g. Library no longer says "Downloaded" where the queue says "Completed").
*/
const STATUS_CHIP: Record<ChipStatus, { label: string; color: ChipColor }> = {
pending: { label: 'Pending', color: 'subtle' },
queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' },
paused: { label: 'Paused', color: 'warning' },
saved: { label: 'Saved', color: 'subtle' },
completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'subtle' }
}
/** Shared status chip used by the download queue and the library item list. */
export function StatusChip({ status }: { status: ChipStatus }): React.JSX.Element {
const { label, color } = STATUS_CHIP[status]
return (
<Badge appearance="tint" color={color}>
{label}
</Badge>
)
}
@@ -0,0 +1,99 @@
import {
Caption1,
Button,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
DismissRegular,
ErrorCircleFilled,
CheckmarkCircleFilled,
InfoFilled
} from '@fluentui/react-icons'
import { useToasts, type ToastTone } from '../../store/toasts'
import { Z, ELEVATION, RADIUS, ICON } from './tokens'
/**
* Renders the transient-toast stack (UX6/UX9) bottom-center. Deliberately NOT a
* Fluent Toaster/portal this app avoids portal-based overlays (they blank/flicker
* on the dev GPU; see Select.tsx / CommandPalette.tsx) so it's a plain fixed
* stack. No enter/exit animation, matching the app's conservative motion policy on
* this machine.
*/
const useStyles = makeStyles({
stack: {
position: 'fixed',
left: '50%',
bottom: '24px',
transform: 'translateX(-50%)',
zIndex: Z.tooltip,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
// Let clicks fall through the gaps between toasts to the app underneath.
pointerEvents: 'none'
},
toast: {
pointerEvents: 'auto',
display: 'flex',
alignItems: 'center',
gap: '10px',
maxWidth: 'min(520px, 90vw)',
padding: '10px 8px 10px 12px',
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
...shorthands.borderRadius(RADIUS.surface),
boxShadow: ELEVATION.overlay
},
icon: {
flexShrink: 0,
fontSize: `${ICON.control}px`,
display: 'flex'
},
info: { color: tokens.colorNeutralForeground3 },
error: { color: tokens.colorStatusDangerForeground1 },
success: { color: tokens.colorStatusSuccessForeground1 },
message: {
flexGrow: 1,
minWidth: 0
}
})
const ICONS: Record<ToastTone, React.JSX.Element> = {
info: <InfoFilled />,
error: <ErrorCircleFilled />,
success: <CheckmarkCircleFilled />
}
export function Toaster(): React.JSX.Element | null {
const styles = useStyles()
const toasts = useToasts((s) => s.toasts)
const dismiss = useToasts((s) => s.dismiss)
const toneClass: Record<ToastTone, string> = {
info: styles.info,
error: styles.error,
success: styles.success
}
if (toasts.length === 0) return null
return (
<div className={styles.stack} role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={styles.toast}>
<span className={mergeClasses(styles.icon, toneClass[t.tone])}>{ICONS[t.tone]}</span>
<Caption1 className={styles.message}>{t.message}</Caption1>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
/>
</div>
))}
</div>
)
}
@@ -0,0 +1,21 @@
import { makeStyles, tokens } from '@fluentui/react-components'
/**
* Shared error-text styling (M12). The red `colorPaletteRedForeground1` was being
* applied via inline `style={{ color: … }}` across the app; this hook is the one
* place that owns it.
*
* - `error` a single line of error copy (e.g. an inline validation/result message).
* - `errorPre` the same colour but preserving newlines/long tokens, for multi-line
* yt-dlp/updater error output that would otherwise overflow.
*/
export const useErrorTextStyles = makeStyles({
error: {
color: tokens.colorPaletteRedForeground1
},
errorPre: {
color: tokens.colorPaletteRedForeground1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
})
@@ -0,0 +1,26 @@
import { makeStyles, tokens } from '@fluentui/react-components'
/**
* One focus-visible ring for every hand-rolled interactive element (UI29).
*
* Fluent's own controls draw their own focus indicator; the app's bespoke
* buttons, segmented controls, command-palette rows, and `role="button"` headers
* previously fell back to the UA default (often invisible) or removed it
* entirely. This gives them all the same visible, theme-aware keyboard focus.
*
* Apply with `mergeClasses(focus.focusRing, …)`. The ring is inset (negative
* offset) so it never clips inside `overflow: hidden` containers and follows each
* element's own border-radius; it shows only for `:focus-visible`, so pointer
* clicks stay quiet.
*/
export const useFocusStyles = makeStyles({
focusRing: {
outlineStyle: 'none',
':focus-visible': {
outlineWidth: '2px',
outlineStyle: 'solid',
outlineColor: tokens.colorStrokeFocus2,
outlineOffset: '-2px'
}
}
})
+52
View File
@@ -0,0 +1,52 @@
import { makeStyles, tokens } from '@fluentui/react-components'
/**
* Shared text-role styles + the app's type-role convention (Batch 7).
*
* The renderer had drifted on typography: the one muted color was re-declared under
* eight class names (L110), row titles were rendered as three different components
* (L114), and `Caption1`/`Body1`/`Text` were applied by feel (L123/L124). The
* convention below makes the roles explicit; this module supplies the two styles
* that back it.
*
* Type roles reach for the Fluent type component, add `muted` for secondary color:
* - `<Subtitle2>` screen titles and settings-section headers (via `ScreenHeader`
* and each settings card's header).
* - `<Text>` the one row/item title component (add `title` for the semibold,
* single-line-with-ellipsis treatment). Previously split across `<Text>`, a bare
* `<span>`, and `<Caption1>` (L114).
* - `<Body1>` body copy: empty-state messages, onboarding prose, gated notices.
* Not for titles or metadata (L124).
* - `<Caption1>` small secondary text: metadata, hints, counts, timestamps. One
* role doing several closely-related jobs, all "small secondary text" (L123).
*/
export const useTextStyles = makeStyles({
/**
* The single muted / secondary-text color (L110) was re-declared as
* hint/sub/meta/srcSub/stats/count/emptyHint/previewMeta. Merge onto a `<Caption1>`
* or `<Text>`, or use alone where the element sets no other style.
*/
muted: {
color: tokens.colorNeutralForeground3
},
/** The shared row/item title treatment (L114): semibold, single line, ellipsis. */
title: {
fontWeight: tokens.fontWeightSemibold,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
/**
* The single-line truncation utility (L126): clip overflowing text to one line
* with an ellipsis. This is `title` without the semibold weight for metadata
* and non-title text that should truncate rather than wrap. Needs `minWidth: 0`
* so it can shrink inside a flex row.
*/
truncate: {
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
+139
View File
@@ -0,0 +1,139 @@
import { tokens } from '@fluentui/react-components'
/**
* AeroFetch design tokens the app's own semantic scales, layered on Fluent's.
*
* Batch 6 of the audit (UI2UI7, UI12, UI26, L100, L101, L121, L125, L160, L164)
* found the same visual decisions expressed as ad-hoc literals scattered across
* components: four section gaps, seven card paddings, two floating shadows, two
* hardcoded `zIndex: 1000`s, a raw `rgba()` scrim, nine inline icon sizes, and
* three metadata separators. This module is the single home for those values so
* every screen reads as one product and a change happens in one place.
*
* Where Fluent already defines a scale (spacing, radius, shadow, motion, the
* overlay scrim) these alias Fluent's tokens, so the app stays pixel-aligned with
* Fluent's own controls. Where Fluent has no token (z-index, icon px, the
* metadata separator) the value lives here.
*
* Griffel evaluates `makeStyles` at runtime in this project (no build plugin see
* `thumbSizes` already imported into `downloadBar/styles.ts`), so these imported
* constants work directly inside `makeStyles` blocks and in inline `style={{}}`.
*/
/**
* Spacing ramp (px). Gaps, padding, and margins all draw from this one scale; the
* comments record the role each step plays so call sites stay self-documenting.
* Values match Fluent's spacing ramp (XXS 2 · XS 4 · S 8 · MNudge 10 · M 12 · L 16
* · XL 20 · XXL 24 · XXXL 32).
*/
export const SPACE = {
/** 2px — optical hairline nudge (e.g. aligning an icon to a text baseline) (L160). */
hairline: '2px',
/** 4px — icon-button clusters (UI5); tight margins between stacked lines (L160). */
xtight: '4px',
/** 8px — labelled-control rows (UI5); the default inline gap. */
tight: '8px',
/** 10px — control inner padding; small tiles. */
snug: '10px',
/** 12px — list-row padding; the gap between rows inside a card. */
cozy: '12px',
/** 16px — the one gap between screen sections (UI2) and the card padding tier (UI3). */
section: '16px',
/** 20px — a larger block gap. */
roomy: '20px',
/** 24px — page content padding (UI4); the hero padding tier's floor. */
page: '24px',
/** 32px — the onboarding / welcome hero card padding (UI3 hero tier). */
hero: '32px'
} as const
/**
* Corner-radius tiers by surface (UI6/UI7). Aliases Fluent's radius tokens, which
* `friendlyRadii` (theme.ts) tunes to Medium 10 · Large 12 · XLarge 16.
*
* - `control` (10) inputs, thumbnails, small tiles, popover rows.
* - `surface` (12) list-item cards and inline panels.
* - `card` (16) page-level cards (the download bar, settings cards, onboarding).
*/
export const RADIUS = {
control: tokens.borderRadiusMedium,
surface: tokens.borderRadiusLarge,
card: tokens.borderRadiusXLarge
} as const
/**
* Elevation scale (L100). Cards are flat by default (border only the app's
* resting surface); only genuinely floating surfaces cast a shadow.
*
* - `flat` the default card: no shadow, a 1px neutral-stroke border.
* - `raised` (shadow4) the download bar, which floats above the queue.
* - `overlay` (shadow28) the command palette and any future popover.
*/
export const ELEVATION = {
flat: 'none',
raised: tokens.shadow4,
overlay: tokens.shadow28
} as const
/**
* Z-index scale (L101). Replaces the two hardcoded `zIndex: 1000`s so stacking is
* ordered on purpose: tooltips sit above full-screen overlays.
*/
export const Z = {
/** full-screen scrims and the command palette. */
overlay: 1000,
/** hint bubbles — above overlays so a tooltip is never clipped by one. */
tooltip: 1100
} as const
/**
* Theme-aware scrim for full-screen overlays (L121). Fluent's overlay token
* replaces the renderer's one raw `rgba(0,0,0,0.32)` — it's the semantic dialog
* scrim and darkens a touch more, so the palette separates cleanly from a dark UI.
*/
export const SCRIM = tokens.colorBackgroundOverlay
/**
* Icon-size scale in px (UI12/L125): one small set instead of the nine scattered
* `fontSize` literals. Applied as a numeric `fontSize={ICON.x}` on standalone
* glyphs, or `` `${ICON.x}px` `` on the rare makeStyles container.
*
* - `inline` (16) glyphs sitting inline with text (row actions, small badges).
* - `control` (20) glyphs inside buttons, nav items, toggles, and small leading
* accent icons (e.g. the onboarding folder/tip rows).
* - `section` (24) section/card-header glyphs (e.g. each settings card's title).
* - `hero` (40) the focal icon in an empty state.
*
* Snapped to the nearest tier rather than a literal px even when centered in a
* fixed tile (UI11 no literal px font sizes): the brand/source tiles
* (Sidebar/Onboarding `mark`, Library `srcIcon`, History `emptyBadge`) take
* `control` or `section`. Genuinely off this scale: the tiny `watchBadge` glyph
* (matched to its badge-caption text size) and the thumbnail placeholder icons
* (which track `thumbSizes`).
*/
export const ICON = {
inline: 16,
control: 20,
section: 24,
hero: 40
} as const
/**
* One motion policy (UI26). The app keeps a single, subtle transition timing
* (matching the sidebar's former `0.15s ease`) and gates *all* transitions and
* animations behind `prefers-reduced-motion` globally in base.css so motion is
* consistent where it exists and fully off for users who ask for less.
*/
export const MOTION = {
duration: tokens.durationFast,
curve: tokens.curveEasyEase
} as const
/**
* The single metadata separator (L164). Joins independent fields on a meta line
* (e.g. `Channel • 12:04`, `1.2 GB/s • 3s left`). Previously three styles were in
* use (`' • '`, `' · '`, `' • '`). Note this is distinct from the *intra-value*
* separator inside a compound quality label ("720p · mp4 · 184 MB"), which is a
* data format parsed elsewhere and intentionally left alone.
*/
export const META_SEP = ' • '
+48
View File
@@ -0,0 +1,48 @@
// One home for the renderer's date/time formatters (M9). These were previously
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
/** Relative "time since" label: "just now" / "5 min ago" / "3 h ago" /
* "2 d ago" / "3 w ago" / "2 mo ago". Returns "never" for a missing
* timestamp. (Library last-indexed.) */
export function relTime(ms?: number): string {
if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago`
const days = Math.round(hrs / 24)
if (days < 7) return `${days} d ago`
const weeks = Math.round(days / 7)
if (weeks < 5) return `${weeks} w ago`
return `${Math.round(days / 30)} mo ago`
}
/** Absolute "when" label: "Today, 3:04 PM" / "Yesterday, 3:04 PM" /
* "Jun 5" (current year) / "Jun 5, 2024" (past year). (History rows.) */
export function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
const sameYear = d.getFullYear() === now.getFullYear()
return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' })
})
}
/** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM".
* Returns '' if the timestamp can't be formatted. (Queue scheduled badge.) */
export function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}

Some files were not shown because too many files have changed in this diff Show More