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

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 361 KiB

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

Before

Width:  |  Height:  |  Size: 699 B

After

Width:  |  Height:  |  Size: 1.4 KiB

+14 -6
View File
@@ -1,6 +1,6 @@
appId: com.aerofetch.app
productName: AeroFetch
copyright: yt-dlp frontend
copyright: Copyright © 2026 AeroFetch
# Lets a browser/another app hand AeroFetch a link via aerofetch://download?url=<encoded>
# (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent.
@@ -15,13 +15,18 @@ directories:
buildResources: build
output: dist
# Generate a <installer>.exe.sha256 next to every built .exe (H8). The updater
# hard-requires this checksum asset or it refuses to install the update.
afterAllArtifactBuild: ./scripts/generate-checksums.cjs
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{eslint.config.js,.prettierrc,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json,tsconfig.tsbuildinfo}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!*.tsbuildinfo'
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
@@ -46,9 +51,9 @@ win:
- portable
# Never request admin elevation.
requestedExecutionLevel: asInvoker
# Placeholder icon (audit M3): white download glyph on the teal brand square,
# mirroring the in-app brand mark. Generated from build/icon.svg as a multi-size
# .ico (256/128/64/48/32/16). Replace with a designed asset before v1.0 — re-run
# App icon (audit W14): white download glyph on the teal brand square with a
# top-lit gradient. Generated from build/icon.svg as a multi-size .ico
# (256/128/64/48/32/16). After editing the SVG, regenerate with:
# magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico
icon: build/icon.ico
@@ -63,4 +68,7 @@ nsis:
perMachine: false
allowToChangeInstallationDirectory: true
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).
+8 -2
View File
@@ -9,7 +9,11 @@ export default defineConfig({
alias: {
'@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: {
plugins: [externalizeDepsPlugin()],
@@ -21,6 +25,7 @@ export default defineConfig({
// Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling
// the sandbox (webPreferences.sandbox) is part of the security hardening.
build: {
sourcemap: 'hidden',
rollupOptions: {
output: {
format: 'cjs',
@@ -36,6 +41,7 @@ export default defineConfig({
'@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",
"main": "./out/main/index.js",
"author": "AeroFetch",
"homepage": "https://github.com/yt-dlp/yt-dlp",
"license": "UNLICENSED",
"homepage": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch",
"repository": {
"type": "git",
"url": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git"
},
"private": true,
"type": "module",
"scripts": {
@@ -14,12 +19,18 @@
"ui:build": "vite build",
"ui:preview": "vite preview",
"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",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"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": {
"@electron-toolkit/utils": "^4.0.0",
@@ -33,6 +44,7 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@eslint/js": "^9.39.4",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
@@ -40,7 +52,12 @@
"electron": "^42.4.1",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"globals": "^15.15.0",
"prettier": "^3.9.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"vite": "^7.3.5",
"vitest": "^4.1.9"
}
+36
View File
@@ -0,0 +1,36 @@
// electron-builder `afterAllArtifactBuild` hook (H8).
//
// The in-app updater (src/main/updater.ts) sets REQUIRE_CHECKSUM = true and
// refuses any release whose installer lacks a sibling `<installer>.exe.sha256`
// asset — so a release published without one makes EVERY client's in-app update
// fail. `build:win` never generated these, so they had to be made by hand (and
// the 0.5.0 build shipped without any). This hook writes one next to every built
// `.exe`, in `sha256sum` format (`<lowercase-hex> <filename>`), which the
// updater's extractSha256 parses (it just needs a standalone 64-char hex token).
//
// Returning the paths tells electron-builder to also upload them as release
// assets when publishing.
const { createHash } = require('crypto')
const { readFileSync, writeFileSync } = require('fs')
const { basename } = require('path')
/** The `sha256sum`-format line for a file: "<lowercase-hex> <basename>". */
function checksumLine(filePath) {
const hex = createHash('sha256').update(readFileSync(filePath)).digest('hex')
return `${hex} ${basename(filePath)}`
}
exports.default = function generateChecksums(context) {
const written = []
for (const file of context.artifactPaths) {
if (!/\.exe$/i.test(file)) continue
const out = `${file}.sha256`
writeFileSync(out, checksumLine(file) + '\n')
written.push(out)
}
return written
}
// Exported for unit testing the hashing/format without running electron-builder.
exports.checksumLine = checksumLine
+30 -20
View File
@@ -1,14 +1,9 @@
import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation'
import type {
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
interface BackupFile {
version: 1
@@ -17,12 +12,18 @@ interface BackupFile {
}
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
const res = await dialog.showSaveDialog(win!, {
const opts = {
defaultPath: 'aerofetch-backup.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 }
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
// Strip credentials (proxy creds, API tokens) so a backup file shared or synced
// to the cloud doesn't leak secrets in plaintext (M22). The user re-enters them
// after import. Shares SECRET_KEYS with settings.ts so the lists can't drift.
const settings = { ...getSettings() }
for (const key of SECRET_KEYS) (settings as Record<string, unknown>)[key] = ''
const payload: BackupFile = { version: 1, settings, templates: listTemplates() }
try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath }
@@ -32,10 +33,11 @@ export async function exportBackup(win: BrowserWindow | undefined): Promise<Back
}
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
const res = await dialog.showOpenDialog(win!, {
properties: ['openFile'],
const openOpts = {
properties: ['openFile' as const],
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 }
let parsed: unknown
@@ -85,10 +87,9 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
return `${name}: ${args}`
})
.join('\n')
const more =
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const choice = await dialog.showMessageBox(win!, {
type: 'warning',
const more = commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const msgOpts = {
type: 'warning' as const,
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
defaultId: 1,
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' +
preview +
more
})
}
const choice = await (win
? dialog.showMessageBox(win, msgOpts)
: dialog.showMessageBox(msgOpts))
if (choice.response === 2) return { ok: false } // Cancel — change nothing
applyCustomCommands = choice.response === 0
}
if (incomingSettings) {
// Never restore credential fields from a backup. Exports strip them (M22), so an
// imported '' would otherwise wipe a proxy/token already configured on this
// machine; honoring a hand-edited one would reintroduce the leak vector. Either
// way, import leaves the user's existing secrets untouched — they re-enter as needed.
const restored: Partial<Settings> = { ...incomingSettings }
for (const key of SECRET_KEYS) delete restored[key]
// When the user declined to enable custom commands (or there were none to
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands
? incomingSettings
: { ...incomingSettings, customCommandEnabled: false }
? restored
: { ...restored, customCommandEnabled: false }
setSettings(safeSettings)
}
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
+87
View File
@@ -0,0 +1,87 @@
import { deflateSync } from 'zlib'
import { nativeImage, type NativeImage } from 'electron'
// CRC32 table polynomial used by PNG.
function crc32(buf: Buffer): number {
let crc = 0xffffffff
for (const b of buf) {
crc ^= b
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
}
return (crc ^ 0xffffffff) >>> 0
}
function pngChunk(type: string, data: Buffer): Buffer {
const t = Buffer.from(type, 'ascii')
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length)
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
return Buffer.concat([len, t, data, crc])
}
/**
* Build a 16×16 RGBA PNG containing a solid-colour filled circle. Used for the
* Windows taskbar overlay badge. No external deps — pure Node.js (Buffer + zlib).
*/
function makeDotPng(r: number, g: number, b: number): NativeImage {
const W = 16,
H = 16
const px = Buffer.alloc(W * H * 4, 0) // transparent bg
const cx = W / 2,
cy = H / 2,
rad = W / 2 - 1.5
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x + 0.5 - cx,
dy = y + 0.5 - cy
if (dx * dx + dy * dy <= rad * rad) {
const i = (y * W + x) * 4
px[i] = r
px[i + 1] = g
px[i + 2] = b
px[i + 3] = 255
}
}
}
// PNG: IHDR (8-bit RGBA, no interlace), filter-0 rows, deflated IDAT, IEND.
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(W, 0)
ihdr.writeUInt32BE(H, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // colour type: RGBA
const rows = Buffer.alloc(H * (1 + W * 4))
for (let y = 0; y < H; y++) {
rows[y * (1 + W * 4)] = 0 // filter type: None
px.copy(rows, y * (1 + W * 4) + 1, y * W * 4, (y + 1) * W * 4)
}
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const png = Buffer.concat([
sig,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(rows)),
pngChunk('IEND', Buffer.alloc(0))
])
return nativeImage.createFromBuffer(png)
}
// Lazy-initialised singletons — created on first use so this module is safe to
// import before the app is ready (nativeImage.createFromBuffer is fine post-ready).
let activeBadge: NativeImage | null = null
let errorBadge: NativeImage | null = null
/** Green dot badge for the taskbar overlay — shown when downloads are active. */
export function getActiveBadge(): NativeImage {
if (!activeBadge) activeBadge = makeDotPng(58, 185, 108) // #3ab96c
return activeBadge
}
/** Red dot badge for the taskbar overlay — shown when a download has errored. */
export function getErrorBadge(): NativeImage {
if (!errorBadge) errorBadge = makeDotPng(217, 48, 37) // #d93025
return errorBadge
}
+23 -2
View File
@@ -1,4 +1,4 @@
import { app } from 'electron'
import { app, nativeImage, type NativeImage } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
@@ -24,6 +24,23 @@ export function getAppIconPath(): string {
: join(process.resourcesPath, 'icon.ico')
}
let appIconImage: NativeImage | null = null
/**
* The app icon as a cached NativeImage, for OS notifications (W13/L127). Loaded
* once from getAppIconPath(); a missing icon yields an empty image, which
* Notification treats as "no icon" (the OS default) rather than erroring. This
* gives completion/background toasts the real brand glyph — notably on the
* portable build, which has no installed AUMID shortcut icon for Windows to use.
*/
export function getAppIconImage(): NativeImage {
// Cache only a valid image so a transient read miss isn't latched forever; a
// genuinely-missing icon just re-reads (cheap — notifications are infrequent).
if (!appIconImage || appIconImage.isEmpty()) {
appIconImage = nativeImage.createFromPath(getAppIconPath())
}
return appIconImage
}
/**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what
@@ -35,7 +52,7 @@ export function getAppIconPath(): string {
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
*/
export function getManagedBinDir(): string {
function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
}
@@ -63,6 +80,10 @@ export function getFfprobePath(): string {
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. */
export function getAria2cPath(): string {
return join(getBinDir(), 'aria2c.exe')
+79 -23
View File
@@ -42,9 +42,7 @@ function videoFormat(quality: string): string {
*/
function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio
? opts.formatId
: `${opts.formatId}+bestaudio/${opts.formatId}`
return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
}
return videoFormat(opts.quality)
}
@@ -57,18 +55,27 @@ function audioQuality(quality: string): string {
return '192K'
case '128 kbps':
return '128K'
case 'Best':
return '0'
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
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
// (it does NOT appear in the output). The literal `prog|` that follows is our
// (it does NOT appear in the output). The PROGRESS_MARKER that follows is our
// own marker, so we can tell progress lines apart from the after-move filepath
// print on the same stdout stream.
export const PROGRESS_TEMPLATE =
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
const PROGRESS_TEMPLATE =
`download:${PROGRESS_MARKER}%(progress.status)s|%(progress.downloaded_bytes)s|` +
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s'
@@ -77,8 +84,7 @@ export const PROGRESS_TEMPLATE =
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
export const CROP_SQUARE_PPA =
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + "crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
/**
* Phase B "Access & networking" settings — not per-download options, always
@@ -127,7 +133,9 @@ export function parseExtraArgs(raw: string): string[] {
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
args.push(m[1] ?? m[2] ?? m[3])
// Exactly one of the three alternation groups matches; the '' fallback only
// satisfies the type checker (noUncheckedIndexedAccess) and never fires.
args.push(m[1] ?? m[2] ?? m[3] ?? '')
}
return args
}
@@ -197,7 +205,10 @@ export function matchesUrl(pattern: string, url: string): boolean {
*/
export function parseTrimSections(raw: string | undefined): string[] {
if (!raw) return []
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
// A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old
// `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside
// yt-dlp's --download-sections rather than being rejected up front (L146).
const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?`
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
const out: string[] = []
for (const piece of raw.split(/[,\n]/)) {
@@ -244,7 +255,11 @@ export function sanitizeDirSegment(name: string): string {
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
s = s
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '')
.replace(/^[. ]+/, '')
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
@@ -328,7 +343,23 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
// Split into one file per chapter. yt-dlp also keeps the full file; the
// per-chapter files use yt-dlp's default `chapter:` output template.
if (o.splitChapters) args.push('--split-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Metadata embedding + optional per-field overrides.
// --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata
// FROM:TO because the replacement arg is a separate element (no colon-splitting
// on values like "Foo: Bar"). ^.*$ matches any string including empty. The only
// escaping Python's re.sub needs in the replacement string is backslash.
const metaOverrides: [string, string | undefined][] = [
['title', o.metadataTitle],
['artist', o.metadataArtist],
['album', o.metadataAlbum]
]
const hasOverrides = metaOverrides.some(([, v]) => v?.trim())
if (o.embedMetadata || hasOverrides) args.push('--embed-metadata')
const escRepl = (s: string): string => s.replace(/\\/g, '\\\\')
for (const [field, val] of metaOverrides) {
if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim()))
}
// Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
@@ -339,18 +370,39 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
return args
}
export function buildArgs(
opts: StartDownloadOptions,
outputTemplate: string,
o: DownloadOptions,
binDir: string,
access: AccessOptions,
extraArgs: string[] = []
): string[] {
/**
* Everything buildArgs needs to construct a yt-dlp argv. A single options object
* (rather than six positional params) so callers can't transpose `opts`/`options`
* or the two path-like strings, and new inputs can be added without churning every
* call site (CL2).
*/
export interface BuildArgsInput {
/** 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 = [
'--newline',
'--no-color',
'--no-playlist',
// Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a
// stalled socket instead of hanging forever and pinning a concurrency slot
// with no recovery. The app-side idle watchdog in download.ts is the backstop
// for a fully-wedged process. (B1)
'--socket-timeout',
'30',
// --print (below) implies --quiet, which would suppress progress; --progress
// forces the progress template to emit anyway.
'--progress',
@@ -362,7 +414,7 @@ export function buildArgs(
PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later.
'--print',
'after_move:path|%(filepath)s',
`after_move:${FILEPATH_MARKER}%(filepath)s`,
'--no-simulate'
]
@@ -370,7 +422,11 @@ export function buildArgs(
args.push(...postProcessArgs(opts, o))
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) {
args.push('--embed-thumbnail')
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 { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
import { logger } from './logger'
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
/**
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
* from the main window's (default) session so a logged-in site can't see or
* touch anything the app itself loads.
* touch anything the app itself loads. Exported so the PO-token window can
* share the same session (and thus the user's YouTube sign-in state).
*/
const PARTITION = 'persist:aerofetch-login'
export const LOGIN_PARTITION = 'persist:aerofetch-login'
/**
* The sign-in window renders untrusted remote content, so every navigation and
@@ -43,7 +45,7 @@ function hardenLoginWebContents(wc: WebContents): void {
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: PARTITION,
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
@@ -60,20 +62,99 @@ function hardenLoginWebContents(wc: WebContents): void {
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
}
export function getCookiesFilePath(): string {
// The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
// a plaintext file, so the stored form is encrypted via Electron safeStorage
// (DPAPI on Windows — the same protection settings secrets get) and only ever
// decrypted to a short-lived temp file for the duration of a download. This
// matters for the portable build, where userData sits next to the exe (USB /
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
function cookieStorePath(): string {
return join(app.getPath('userData'), 'cookies.dat')
}
function legacyCookiePath(): string {
return join(app.getPath('userData'), 'cookies.txt')
}
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
const ENC_PREFIX = 'enc:v1:'
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
function storeCookies(netscape: string): void {
let out = netscape
try {
if (safeStorage.isEncryptionAvailable()) {
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
}
} catch {
/* fall through — store plaintext, as it was before encryption existed */
}
writeFileSync(cookieStorePath(), out)
}
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
function loadCookies(): string | null {
const p = cookieStorePath()
if (!existsSync(p)) return null
const stored = readFileSync(p, 'utf8')
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
try {
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
} catch {
// A different Windows user/machine (portable jar copied elsewhere) or a
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
return null
}
}
/**
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
* encrypted store, deleting the plaintext so a logged-in session no longer sits
* in the open after an update (H7). Best-effort; safe to call on every launch.
*/
export function migrateLegacyCookies(): void {
const legacy = legacyCookiePath()
try {
if (!existsSync(legacy)) return
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
unlinkSync(legacy)
} catch {
/* best-effort */
}
}
/** Whether a stored cookie jar exists (regardless of decryptability here). */
export function hasStoredCookies(): boolean {
return existsSync(cookieStorePath())
}
/**
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
* false when there's nothing usable to write. The caller deletes `dest` once the
* download settles, keeping the plaintext window as short as possible.
*/
export function materializeCookies(dest: string): boolean {
const text = loadCookies()
if (text == null) return false
try {
writeFileSync(dest, text)
return true
} catch {
return false
}
}
export function getCookiesStatus(): CookiesStatus {
const p = getCookiesFilePath()
const p = cookieStorePath()
if (!existsSync(p)) return { exists: false }
return { exists: true, savedAt: statSync(p).mtimeMs }
}
export async function clearCookies(): Promise<void> {
const p = getCookiesFilePath()
const p = cookieStorePath()
if (existsSync(p)) unlinkSync(p)
await session.fromPartition(PARTITION).clearStorageData()
await session.fromPartition(LOGIN_PARTITION).clearStorageData()
}
/**
@@ -89,9 +170,15 @@ function toNetscapeCookieFile(cookies: Cookie[]): string {
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
lines.push(
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
'\t'
)
[
domain,
includeSubdomains,
c.path || '/',
c.secure ? 'TRUE' : 'FALSE',
expiry,
c.name,
c.value
].join('\t')
)
}
return lines.join('\n') + '\n'
@@ -102,10 +189,13 @@ let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
function exportAndResolve(): void {
session
.fromPartition(PARTITION)
.fromPartition(LOGIN_PARTITION)
.cookies.get({})
.then((cookies) => {
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
// Don't persist an empty jar: closing the window without signing in would
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
// turns cookieCount === 0 into a "did you sign in?" hint.
if (cookies.length > 0) storeCookies(toNetscapeCookieFile(cookies))
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
pendingResolvers.forEach((resolve) => resolve(result))
})
@@ -124,7 +214,10 @@ function exportAndResolve(): void {
* window — cookies are exported to a Netscape-format file at that point,
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
*/
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> {
export function openCookieLoginWindow(
url: string,
parent?: BrowserWindow
): Promise<CookiesLoginResult> {
return new Promise((resolve) => {
let validUrl: string
try {
@@ -136,7 +229,7 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
if (loginWindow && !loginWindow.isDestroyed()) {
pendingResolvers.push(resolve)
loginWindow.loadURL(validUrl).catch(() => {})
loginWindow.loadURL(validUrl).catch((e) => logger.error('cookie login loadURL failed', e))
loginWindow.focus()
return
}
@@ -149,8 +242,11 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
height: 720,
title: 'Sign in — AeroFetch',
autoHideMenuBar: true,
// Group under the app window instead of taking its own taskbar button
// (W6); a child window stays above its parent without a modal block.
parent: parent && !parent.isDestroyed() ? parent : undefined,
webPreferences: {
partition: PARTITION,
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
@@ -170,13 +266,26 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
hardenLoginWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
// 'close' fires on a normal user close; a programmatic destroy() fires only
// 'closed'. Latch whichever runs first so the partition is exported exactly
// once and the promise can never hang (B7) — without this, a destroy()
// without a preceding 'close' would leave pendingResolvers pending forever.
let exportStarted = false
const exportOnce = (): void => {
if (exportStarted) return
exportStarted = true
exportAndResolve()
}
win.on('closed', () => {
loginWindow = null
// Fallback for destroy()/closed-without-close: the partition outlives the
// window, so a late export still captures whatever cookies were collected.
exportOnce()
})
// Closing the window IS "I'm done" — export whatever the partition
// collected. The partition itself outlives the window, so this is safe
// even though cookies.get() resolves after 'close' has already fired.
win.on('close', exportAndResolve)
win.on('close', exportOnce)
win.loadURL(validUrl).catch(() => {
/* navigation errors surface as Chromium's own error page */
+3 -2
View File
@@ -2,6 +2,7 @@ import { app, shell, type BrowserWindow } from 'electron'
import { existsSync, openSync, readSync, closeSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
import { parseUrlShortcutContent } from '@shared/ipc'
/** 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.
@@ -29,8 +30,8 @@ function readUrlShortcut(path: string): string | null {
fd = openSync(path, 'r')
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
const bytes = readSync(fd, buf, 0, buf.length, 0)
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
return match ? asHttpUrl(match[1].trim()) : null
const url = parseUrlShortcutContent(buf.toString('utf8', 0, bytes))
return url ? asHttpUrl(url) : null
} catch {
return null
} finally {
+247 -130
View File
@@ -1,5 +1,6 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { existsSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
@@ -8,11 +9,16 @@ import {
getAria2cPath,
getFfmpegPath,
getFfprobePath,
getSystem32Path
getSystem32Path,
getAppIconImage,
YTDLP_MISSING_MSG
} 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 { getCookiesFilePath } from './cookies'
import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation'
@@ -20,10 +26,18 @@ import {
buildArgs,
selectExtraArgs,
formatCommandLine,
collectionOutputTemplate
collectionOutputTemplate,
PROGRESS_MARKER,
FILEPATH_MARKER
} from './buildArgs'
import { cleanError } from './log'
import { addErrorLog } from './errorlog'
import {
STALL_TIMEOUT_MS,
META_PROBE_TIMEOUT_MS,
META_MAX_BUFFER,
STDERR_TAIL_BYTES
} from './constants'
import {
IpcChannels,
type StartDownloadOptions,
@@ -31,7 +45,6 @@ import {
type CommandPreviewResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress,
type Settings
} from '@shared/ipc'
@@ -43,6 +56,21 @@ interface 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
* 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) ----------------
function num(s?: string): number | undefined {
if (!s || s === 'NA') return undefined
const n = Number(s)
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
}
}
// parseProgress lives in lib/formatters.ts (no electron import chain) so it can
// be unit-tested in isolation (L37). Byte/ETA formatters live in @shared/format.
export { parseProgress } from './lib/formatters'
import { parseProgress } from './lib/formatters'
function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}
/** 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
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', () => {
if (wc.isDestroyed()) return
const win = BrowserWindow.fromWebContents(wc)
@@ -120,9 +107,17 @@ function notify(wc: WebContents, title: string, body: string): void {
}
})
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 {
// 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({
id: opts.id,
title,
@@ -135,37 +130,34 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
// --- Best-effort metadata probe (runs alongside the download) ---------------
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
return new Promise((resolve) => {
execFile(
ytdlp,
[
'--no-playlist',
'--no-warnings',
'--skip-download',
'--print',
'title',
'--print',
'uploader',
'--print',
'duration_string',
'--',
url
],
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
(err, stdout) => {
if (err) return resolve(null)
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
const clean = (v?: string): string | undefined =>
v && v !== 'NA' ? v : undefined
resolve({
title: clean(title),
channel: clean(uploader),
durationLabel: clean(duration)
})
}
)
})
// Unit Separator (0x1F): a control char that can't appear in a title/uploader,
// so it safely delimits the three fields in ONE --print template. Splitting on
// newlines instead (B4) would mis-assign channel/duration whenever a title
// itself contains a newline, since each --print field is emitted on its own line.
const META_SEP = '\u001f'
async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
const r = await execFileAsync(
ytdlp,
[
'--no-playlist',
'--no-warnings',
'--skip-download',
'--print',
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
'--',
url
],
{ maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS }
)
if (!r.ok) return null
const [title, uploader, duration] = r.stdout.split(META_SEP).map((l) => l.trim())
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
return {
title: clean(title),
channel: clean(uploader),
durationLabel: clean(duration)
}
}
// --- 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
// in the renderer UI, so the renderer can't be trusted to apply it.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
// Gate the file read: listTemplates() parses templates.json on every call, so
// skip it entirely when the feature is off (PERF2).
if (!settings.customCommandEnabled) return []
return selectExtraArgs({
customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs,
@@ -184,8 +179,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
})
}
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
/** Resolve settings + per-download overrides into the full yt-dlp argv.
* `cookiesFile` is the transient decrypted jar the caller materialises for the
* 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
const settings = getSettings()
// Output dir resolution: a per-download override wins, then the user's explicit
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
@@ -210,17 +207,13 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
// into resources/bin — the toggle shouldn't turn into a hard error.
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
// Same idea: 'login' cookies only apply once the sign-in window has actually
// exported a file; otherwise the download proceeds cookie-less rather than failing.
const cookiesFile =
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
? getCookiesFilePath()
: undefined
const access = {
proxy: settings.proxy,
rateLimit: settings.rateLimit,
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,
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
@@ -228,7 +221,7 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
youtubePoToken: settings.youtubePoToken
}
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. */
@@ -250,10 +243,7 @@ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult
// --- Public API -------------------------------------------------------------
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
// Self-heal the managed copy from the bundled seed before spawning, so a
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
ensureManagedYtdlp()
@@ -261,7 +251,7 @@ export function startDownload(
if (!existsSync(ytdlp)) {
return {
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/
@@ -275,9 +265,7 @@ export function startDownload(
if (missingBins.length > 0) {
return {
ok: false,
error:
`${missingBins.join(' and ')} not found in ${getBinDir()}\n` +
`Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
error: `${missingBins.join(' and ')} not found in ${getBinDir()}. 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,
@@ -301,10 +289,34 @@ export function startDownload(
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
}
// Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
// yt-dlp reads --cookies once at startup; we delete it the moment the download
// settles, so the plaintext never lingers at rest.
let cookiesFile: string | undefined
// 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
try {
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true })
} catch (e) {
cleanupCookies()
return { ok: false, error: (e as Error).message }
}
@@ -324,66 +336,162 @@ export function startDownload(
// it in parallel so the card fills in quickly.
probeMeta(ytdlp, opts.url).then((meta) => {
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 filePath: string | undefined
// Latched once the first download stream reports 'finished'; flags later
// progress as the merge/post-processing "finishing" phase (SR7).
let finishing = false
// 'error' and 'close' can both fire for one process; only act on the first.
let settled = false
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString()
let nl: number
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line.startsWith('prog|')) {
const p = parseProgress(line.slice('prog|'.length))
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
} else if (line.startsWith('path|')) {
filePath = line.slice('path|'.length).trim()
}
// Idle watchdog (B1): reset on any output; if it ever fires, the child has been
// silent for STALL_TIMEOUT_MS, so kill + error it and free the slot.
let stallTimer: ReturnType<typeof setTimeout> | null = null
function clearWatchdog(): void {
if (stallTimer) {
clearTimeout(stallTimer)
stallTimer = null
}
}
function bumpWatchdog(): void {
clearWatchdog()
stallTimer = setTimeout(() => {
// 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) => {
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
bumpWatchdog()
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES)
})
child.on('error', (err) => {
if (settled) return
settled = true
active.delete(opts.id)
clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
// A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message)
logFailure(opts, getTitle(), err.message)
notify(
wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
err.message,
opts.incognito
)
}
})
child.on('close', (code) => {
if (settled) return
settled = true
active.delete(opts.id)
clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event.
if (rec.canceled || rec.paused) return
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
notify(
wc,
opts.incognito ? 'Download complete' : (getTitle() ?? 'Download complete'),
'Finished downloading.',
opts.incognito
)
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, resolvedTitle, msg)
notify(wc, resolvedTitle ?? 'Download failed', msg)
logFailure(opts, getTitle(), 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
@@ -407,6 +515,11 @@ export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
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)
}
@@ -421,5 +534,9 @@ export function pauseDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
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)
}
+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 { 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
// report survives the queue item being cleared (Seal's "debug report").
const MAX_ENTRIES = 200
// report survives the queue item being cleared (Seal's "debug report"). Atomic
// 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[] {
try {
if (!existsSync(errorLogFile())) return []
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
} catch {
return []
}
}
function save(entries: ErrorLogEntry[]): void {
try {
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted error log */
}
return store.read()
}
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
const entries = [entry, ...listErrorLog()]
save(entries)
return entries
return store.write([entry, ...listErrorLog()])
}
export function clearErrorLog(): ErrorLogEntry[] {
save([])
return []
return store.write([])
}
+9 -14
View File
@@ -1,6 +1,7 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getFfmpegPath, getFfprobePath } from './binaries'
import { execFileAsync } from './lib/exec'
import { VERSION_TIMEOUT_MS } from './constants'
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
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
*/
function readToolVersion(path: string): Promise<string | null> {
if (!existsSync(path)) return Promise.resolve(null)
return new Promise((resolve) => {
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const firstLine = stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
resolve(m ? m[1] : null)
})
})
async function readToolVersion(path: string): Promise<string | null> {
if (!existsSync(path)) return null
const r = await execFileAsync(path, ['-version'], { timeout: VERSION_TIMEOUT_MS })
if (!r.ok) return null
const firstLine = r.stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
return m?.[1] ?? null
}
/** 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 { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { HistoryEntry } from '@shared/ipc'
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation'
import { createJsonStore } from './jsonStore'
// Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later.
const MAX_ENTRIES = 500
// Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic
// 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[] {
try {
if (!existsSync(historyFile())) return []
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
} catch {
return []
}
}
function save(entries: HistoryEntry[]): void {
try {
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted history */
}
return store.read()
}
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
// De-dupe by id (a retry of the same item replaces its prior entry).
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)]
save(entries)
return entries
// De-dupe by id AND url (M35): a History re-download re-queues via addFromUrl,
// which mints a NEW id, so id-only de-dup would let the same video accumulate a
// fresh row on every re-download. Dropping any prior entry with the same url
// keeps one row per video, refreshed to the top.
const prior = listHistory().filter((e) => e.id !== entry.id && e.url !== entry.url)
return store.write([entry, ...prior])
}
export function removeHistory(id: string): HistoryEntry[] {
const entries = listHistory().filter((e) => e.id !== id)
save(entries)
return entries
return store.write(listHistory().filter((e) => e.id !== id))
}
export function removeManyHistory(ids: string[]): HistoryEntry[] {
const remove = new Set(ids)
const entries = listHistory().filter((e) => !remove.has(e.id))
save(entries)
return entries
return store.write(listHistory().filter((e) => !remove.has(e.id)))
}
export function clearHistory(): HistoryEntry[] {
save([])
return []
return store.write([])
}
+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 { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { IpcChannels } from '@shared/ipc'
import { PAGE_BACKGROUND } from '@shared/theme'
import {
IpcChannels,
type StartDownloadOptions,
type Settings,
type HistoryEntry,
type CommandTemplate,
type YtdlpUpdateChannel,
type SystemThemeInfo,
type TaskbarProgress
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
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'
registerIpcHandlers,
resolveBackgroundMode,
applyNativeTheme,
getSystemThemeInfo
} from './ipc'
import { runStartupYtdlpAutoUpdate } from './ytdlp'
import { getAppIconImage } from './binaries'
import { hasActiveDownloads } from './download'
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
import { ensureMediaDirs } from './paths'
import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { exportBackup, importBackup } from './backup'
import { migrateLegacyCookies } from './cookies'
import { attachEditContextMenu } from './contextMenu'
import { flushAllStores } from './jsonStore'
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
import {
listSources,
getSource,
removeSource,
listMediaItems,
setMediaItemDownloaded,
setSourceWatched
} from './sources'
import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
import { isSyncLaunch } from './schedule'
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
// 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`)
// so it's registered even before first launch; this call additionally covers
// the portable build and dev, which have no installer step to do it for us.
if (is.dev && process.argv.length >= 2) {
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])])
const devScript = process.argv[1]
if (is.dev && devScript) {
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)])
} else {
app.setAsDefaultProtocolClient('aerofetch')
}
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
// 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.
@@ -116,7 +70,8 @@ function notifyBackgroundOnce(): void {
notifiedBackground = true
new Notification({
title: 'AeroFetch is still running',
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.'
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
icon: getAppIconImage()
}).show()
}
@@ -140,11 +95,22 @@ const DENIED_PERMISSIONS = new Set([
])
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({
width: 920,
height: 700,
title: 'AeroFetch',
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,
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
backgroundColor: PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
@@ -155,6 +121,22 @@ function createWindow(): void {
})
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', () => {
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
// 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
// menu / before-quit) sets isQuitting() so this lets the close through.
win.on('close', (e) => {
// Capture the final bounds even when the close only hides to tray (W2).
saveWindowState(win)
if (isQuitting()) return
const downloadsRunning = hasActiveDownloads()
if (getSettings().minimizeToTray || downloadsRunning) {
@@ -183,6 +167,13 @@ function createWindow(): void {
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
// 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.
@@ -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
// background in sync for windows currently following 'system'.
function registerSystemThemeBridge(): void {
nativeTheme.on('updated', () => {
const info = getSystemThemeInfo()
const bg = THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
const bg = PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IpcChannels.systemThemeUpdate, info)
if (getSettings().theme === 'system') win.setBackgroundColor(bg)
@@ -400,6 +242,10 @@ if (isPrimaryInstance) {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
// M31: suppress the default Electron menu (which exposes DevTools/Reload via
// Alt) in production builds. In dev the menu is kept so DevTools are accessible.
if (!is.dev) Menu.setApplicationMenu(null)
// Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind).
ensureMediaDirs()
@@ -407,6 +253,9 @@ if (isPrimaryInstance) {
// Encrypt any credential still stored as legacy plaintext (from before at-rest
// encryption), once safeStorage is available post-ready.
migrateSecretsAtRest()
// Same for a pre-encryption plaintext cookies.txt — encrypt it and delete the
// plaintext so a logged-in session no longer sits in the open (H7).
migrateLegacyCookies()
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
// reflects the user's choice even if they changed it on another install.
@@ -416,9 +265,11 @@ if (isPrimaryInstance) {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
registerIpcHandlers(() => mainWindow)
registerSystemThemeBridge()
registerSendToShortcut()
// Apply the persisted theme to the OS title bar before the window opens (W3).
applyNativeTheme(getSettings().theme)
createWindow()
createTray(() => mainWindow)
@@ -443,20 +294,20 @@ if (isPrimaryInstance) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
}
}).catch(() => {})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
}).catch((e) => logger.error('yt-dlp auto-update failed', e))
})
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
// the quitting flag so the window's close handler exits instead of hiding to tray.
app.on('before-quit', () => markQuitting())
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
// Also flush any debounced JSON-store writes synchronously so a quit mid-debounce
// can't drop the last history/sources/template change (R3).
app.on('before-quit', () => {
markQuitting()
flushAllStores()
})
// Windows-only app: closing the last window quits (the tray/in-flight-download
// paths hide rather than close, so this only fires on a real exit). The former
// macOS 'activate' handler and darwin guard were dead branches here (L147).
app.on('window-all-closed', () => app.quit())
}
+23 -25
View File
@@ -9,9 +9,10 @@
* this module is the impure shell that spawns yt-dlp and writes to disk.
*/
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { 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 { assertHttpUrl } from './url'
import {
@@ -45,28 +46,24 @@ interface FlatInfo {
* flat upload list can be a few MB of JSON; the timeout is generous for the same
* reason. `--` terminates option parsing so the URL can't be read as a flag.
*/
function probeFlat(url: string): Promise<FlatInfo> {
return new Promise((resolve, reject) => {
execFile(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out indexing source. Check the link or your connection.'
: cleanError(stderr) || err.message
reject(new Error(msg))
return
}
try {
resolve(JSON.parse(stdout) as FlatInfo)
} catch {
reject(new Error('Could not parse source info from yt-dlp.'))
}
}
async function probeFlat(url: string): Promise<FlatInfo> {
const r = await execFileAsync(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS }
)
if (!r.ok) {
throw new Error(
r.timedOut
? 'Timed out indexing source. Check the link or your connection.'
: cleanError(r.stderr) || r.error?.message || ''
)
})
}
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. */
@@ -90,7 +87,7 @@ export async function indexSource(
return { ok: false, error: (e as Error).message }
}
if (!existsSync(getYtdlpPath())) {
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' }
return { ok: false, error: YTDLP_MISSING_MSG }
}
const cls = classifySource(url)
@@ -152,7 +149,8 @@ export async function indexSource(
if (entries.length === 0) {
return {
ok: false,
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
error:
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
}
}
title = data.title || 'Playlist'
+5 -2
View File
@@ -88,7 +88,10 @@ export function classifySource(raw: string): SourceClass | null {
// A playlist is identified purely by its list= param (works on any youtube host).
const list = u.searchParams.get('list')
if (isYouTube && list) {
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
return {
kind: 'playlist',
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
}
}
if (!isYouTube) return null
@@ -163,7 +166,7 @@ export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = []
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
let m: RegExpExecArray | null
while ((m = re.exec(xml)) !== null) ids.push(m[1])
while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
return ids
}
+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 { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { execFileAsync } from './lib/exec'
import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
import { fmtBytes } from '@shared/format'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
@@ -69,7 +70,10 @@ function buildVideoFormats(raw: RawFormat[]): FormatOption[] {
const options = [...bestByHeight.values()]
.sort((a, b) => (b.height ?? 0) - (a.height ?? 0))
.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
}
@@ -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.
* `--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()
if (!existsSync(ytdlp)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
})
return { ok: false, error: YTDLP_MISSING_MSG }
}
let target: string
try {
target = assertHttpUrl(url) // normalised form (audit F5)
} catch (e) {
return Promise.resolve({ ok: false, error: (e as Error).message })
return { ok: false, error: (e as Error).message }
}
return new Promise((resolve) => {
execFile(
ytdlp,
// `--` terminates option parsing so the URL can never be read as a flag.
['-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.' })
}
}
)
// `--` terminates option parsing so the URL can never be read as a flag.
const r = await execFileAsync(ytdlp, ['-J', '--flat-playlist', '--no-warnings', '--', target], {
maxBuffer: PROBE_MAX_BUFFER,
timeout: PROBE_TIMEOUT_MS
})
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([
// video
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv',
'.mp4',
'.mkv',
'.webm',
'.mov',
'.avi',
'.flv',
'.ts',
'.m4v',
'.3gp',
'.ogv',
// audio
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma',
'.mp3',
'.m4a',
'.opus',
'.ogg',
'.oga',
'.aac',
'.flac',
'.wav',
'.wma',
// subtitle sidecars (plain text — safe to open)
'.vtt', '.srt'
'.vtt',
'.srt'
])
/** Open a downloaded media file with its default app. Returns '' on success,
@@ -35,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise<string> {
return shell.openPath(p)
}
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */
export function safeShowInFolder(p: unknown): void {
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return
/** Reveal a path in the OS file manager. Returns '' on success, or an error
* string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
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)
return ''
}
+9 -4
View File
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
return new Promise((resolve) => {
// Resolve schtasks from System32 by absolute path, not the bare name, so a
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
})
execFile(
getSystem32Path('schtasks.exe'),
args,
{ windowsHide: true },
(err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
}
)
})
}
+89 -87
View File
@@ -1,8 +1,7 @@
import { app, safeStorage } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import { logger } from './logger'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
@@ -10,50 +9,24 @@ import {
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
DEFAULT_DOWNLOAD_OPTIONS,
DEFAULT_SETTINGS,
isYtdlpUpdateChannel,
type Settings,
type DownloadOptions,
type SponsorBlockCategory
type SponsorBlockCategory,
type AudioFormat,
type VideoContainer,
type VideoCodecPref
} from '@shared/ipc'
const DEFAULTS: Settings = {
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoDir: '',
audioDir: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
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: ''
}
// The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared
// contract — single-sourced so the renderer FALLBACK and the preview mock can't
// drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives
// alongside DEFAULT_SETTINGS in shared/ipc.ts.
const DEFAULTS: Settings = DEFAULT_SETTINGS
/**
* 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
// defaults for any missing/invalid field. Used both to migrate older settings
// files (which predate downloadOptions) and to validate renderer writes.
function sanitizeOptions(input: unknown): DownloadOptions {
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
const d = DEFAULT_DOWNLOAD_OPTIONS
const bool = (v: unknown, fallback: boolean): boolean =>
typeof v === 'boolean' ? v : fallback
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
const cats = Array.isArray(o.sponsorBlockCategories)
? (o.sponsorBlockCategories.filter((c) =>
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
) as SponsorBlockCategory[])
: d.sponsorBlockCategories
return {
audioFormat: AUDIO_FORMATS.includes(o.audioFormat as never) ? o.audioFormat! : d.audioFormat,
videoContainer: VIDEO_CONTAINERS.includes(o.videoContainer as never)
? o.videoContainer!
audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string)
? (o.audioFormat as AudioFormat)
: d.audioFormat,
videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string)
? (o.videoContainer as VideoContainer)
: d.videoContainer,
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
? o.preferredVideoCodec!
preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes(
o.preferredVideoCodec as string
)
? (o.preferredVideoCodec as VideoCodecPref)
: d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
@@ -133,6 +79,9 @@ function sanitizeOptions(input: unknown): DownloadOptions {
embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined,
metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined,
metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined,
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
@@ -149,13 +98,19 @@ function getStore(): Store<Settings> {
return store
}
// Decrypted-settings cache — avoids repeated DPAPI calls on hot paths such as
// buildCommand, the maxConcurrent check, completion notify, and the system-theme
// bridge (PERF1). Invalidated by every setSettings write and by the one-time
// migrateSecretsAtRest so callers always see the current values.
let cachedSettings: Settings | null = null
// --- Credential encryption at rest ------------------------------------------
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
// so a leaked settings.json doesn't expose them. They're still decrypted before
// reaching the renderer and exported in clear by backup — this guards the file at
// rest only.
const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
// so a leaked settings.json doesn't expose them. They're decrypted before
// reaching the renderer; backup export strips them entirely (see backup.ts, M22).
// Exported so backup.ts shares this one list rather than keeping a parallel copy.
export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
// legacy plaintext value (written before encryption existed, or while safeStorage
@@ -172,6 +127,11 @@ function encryptSecret(plain: string): string {
} catch {
/* 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
}
@@ -213,9 +173,11 @@ export function migrateSecretsAtRest(): void {
const raw = s.get(key) ?? ''
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
}
cachedSettings = null
}
export function getSettings(): Settings {
if (cachedSettings) return cachedSettings
const s = getStore()
// getSettings() is on hot paths (buildCommand, notification checks, the system-
// theme bridge, several IPC handlers). electron-store writes to disk on every
@@ -236,9 +198,15 @@ export function getSettings(): Settings {
if (!(ACCENT_COLORS as readonly string[]).includes(cur.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
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
return withDecryptedSecrets(s.store)
cachedSettings = withDecryptedSecrets(s.store)
return cachedSettings
}
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
@@ -264,6 +232,20 @@ function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
// background (theme), so an out-of-range or malformed value shouldn't get stored.
export function setSettings(partial: Partial<Settings>): Settings {
const s = getStore()
try {
applySettings(s, partial)
} catch (e) {
// R5: a write failure (disk full, read-only profile) must not crash the IPC
// handler or surface as an unhandled rejection. Log it; getSettings() below
// returns the store's ACTUAL persisted state, so the renderer's reconciliation
// (M34) reflects what truly saved instead of the optimistic value.
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)[]) {
const value = partial[key]
if (value === undefined) continue
@@ -294,6 +276,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
case 'minimizeToTray':
case 'sidebarCollapsed':
if (typeof value === 'boolean') s.set(key, value)
break
case 'launchAtStartup':
@@ -343,23 +326,42 @@ export function setSettings(partial: Partial<Settings>): Settings {
}
break
case 'defaultVideoQuality':
if (
typeof value === 'string' &&
(VIDEO_QUALITY_OPTIONS as readonly string[]).includes(value)
) {
s.set('defaultVideoQuality', value)
}
break
case 'defaultAudioQuality':
if (
typeof value === 'string' &&
(AUDIO_QUALITY_OPTIONS as readonly string[]).includes(value)
) {
s.set('defaultAudioQuality', value)
}
break
case 'rateLimit':
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
if (typeof value === 'string' && /^(\d+\.?\d*[KMGkmg]?B?)?$/.test(value.trim())) {
s.set('rateLimit', value.trim())
}
break
case 'youtubePlayerClient':
if (typeof value === 'string') s.set(key, value)
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
if (typeof value === 'string') s.set('youtubePlayerClient', value.trim())
break
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
// may embed user:pass@host; youtubePoToken is an access token. exportBackup
// still writes them in clear (via the decrypted getSettings), as documented.
// may embed user:pass@host; youtubePoToken is an access token. Both are
// stripped from backup exports (see SECRET_KEYS / backup.ts).
case 'proxy':
case 'youtubePoToken':
if (typeof value === 'string') s.set(key, encryptSecret(value))
break
case 'updateToken':
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
// An access token has no spaces; trim before encrypting (like proxy creds).
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
break
}
}
return getSettings()
}
+29 -52
View File
@@ -2,58 +2,39 @@
* Persistence for the media-manager index (Pinchflat-style; see
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
* better-sqlite3 revisit if a user indexes many large channels, see the
* better-sqlite3 -- revisit if a user indexes many large channels, see the
* Phase H risk note in the roadmap):
*
* sources.json one Source record per added channel/playlist
* media-items.json every MediaItem across all sources (queried by sourceId)
* sources.json -- one Source record per added channel/playlist
* media-items.json -- every MediaItem across all sources (queried by sourceId)
*
* Per-row validation on read (validation.ts) so a hand-edited or corrupted file
* can't feed the UI malformed records (same approach as history/errorlog).
*/
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore'
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
// enough for several big channels. When exceeded, the most-recently-written
// source's items are kept (they're placed first by replaceMediaItems).
const MAX_ITEMS = 20000
function sourcesFile(): string {
return join(app.getPath('userData'), 'sources.json')
}
function itemsFile(): string {
return join(app.getPath('userData'), 'media-items.json')
}
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
try {
if (!existsSync(path)) return []
const data = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(data) ? data.filter(isValid) : []
} catch {
return []
}
}
function writeJson(path: string, value: unknown): void {
try {
writeFileSync(path, JSON.stringify(value, null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted index */
}
}
// Two cached, atomically-written stores (R1R3 via the shared jsonStore). The
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
// rewrite the whole (≤MAX_ITEMS) file on every completion; now reads hit the
// cache and writes are batched. Sources are few, so that store is uncapped.
const sourcesStore = createJsonStore('sources.json', isValidSource, Infinity)
// Compact (not pretty) -- this store can hold up to MAX_ITEMS rows; indentation
// would needlessly inflate its size and write time (R8).
const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS, false)
// --- Sources ----------------------------------------------------------------
export function listSources(): Source[] {
return readJsonArray(sourcesFile(), isValidSource)
return sourcesStore.read()
}
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). */
export function upsertSource(source: Source): Source[] {
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
writeJson(sourcesFile(), sources)
return sources
return sourcesStore.write([source, ...listSources().filter((s) => s.id !== source.id)])
}
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
export function setSourceWatched(id: string, watched: boolean): Source[] {
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
writeJson(sourcesFile(), sources)
return sources
return sourcesStore.write(listSources().map((s) => (s.id === id ? { ...s, watched } : s)))
}
/** Remove a source and all of its media items. Returns the remaining sources. */
export function removeSource(id: string): Source[] {
const sources = listSources().filter((s) => s.id !== id)
writeJson(sourcesFile(), sources)
const items = listAllItems().filter((m) => m.sourceId !== id)
writeJson(itemsFile(), items)
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
itemsStore.write(listAllItems().filter((m) => m.sourceId !== id))
return sources
}
// --- Media items ------------------------------------------------------------
function listAllItems(): MediaItem[] {
return readJsonArray(itemsFile(), isValidMediaItem)
return itemsStore.read()
}
export function listMediaItems(sourceId: string): MediaItem[] {
@@ -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
* (re)index). Other sources' items are preserved; the new items are placed first
* so they survive the MAX_ITEMS cap.
* (re)index). Other sources' items are preserved in full; only the source being
* 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 {
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) =>
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)
}
+2 -1
View File
@@ -8,6 +8,7 @@
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
import { FEED_FETCH_TIMEOUT_MS } from './constants'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
/** 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
// caught by the caller, which then falls back to a full yt-dlp re-index.
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
if (!res.ok) throw new Error(`feed responded ${res.status}`)
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 { isTemplateLike } from './validation'
import { createJsonStore } from './jsonStore'
import { TEMPLATES_MAX } from './constants'
// Plain JSON in userData, same shape as history.ts.
const MAX_TEMPLATES = 100
function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
// backup / caching come from the shared jsonStore (R1R3).
// A persisted template entry must at least be an object carrying an id (see
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
// Drop anything that isn't, so a hand-edited templates.json can't inject
// malformed entries. (audit S5)
export function listTemplates(): CommandTemplate[] {
try {
if (!existsSync(templatesFile())) return []
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
// Validate shape, then normalise each surviving entry through sanitize() so
// name/args are always well-formed strings regardless of what was on disk.
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
} catch {
return []
}
}
const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX)
function save(templates: CommandTemplate[]): void {
try {
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted templates */
}
export function listTemplates(): CommandTemplate[] {
// Normalise each surviving entry through sanitize() so name/args are always
// well-formed strings regardless of what was on disk.
return store.read().map(sanitize)
}
function sanitize(t: CommandTemplate): CommandTemplate {
@@ -49,20 +31,14 @@ function sanitize(t: CommandTemplate): CommandTemplate {
/** Add a new template, or update an existing one (matched by id). */
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
const clean = sanitize(template)
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
save(templates)
return templates
return store.write([clean, ...listTemplates().filter((t) => t.id !== clean.id)])
}
export function removeTemplate(id: string): CommandTemplate[] {
const templates = listTemplates().filter((t) => t.id !== id)
save(templates)
return templates
return store.write(listTemplates().filter((t) => t.id !== id))
}
/** Replace the entire template list (backup restore) rather than merge-by-id. */
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
const clean = templates.map(sanitize)
save(clean)
return clean
return store.write(templates.map(sanitize))
}
+8 -14
View File
@@ -14,6 +14,7 @@ import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { parseExtraArgs } from './buildArgs'
import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>()
@@ -47,18 +48,12 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
active.set(id, child)
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
buffers[stream] += chunk
let nl: number
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
buffers[stream] = buffers[stream].slice(nl + 1)
send(wc, { type: 'output', id, line, stream })
}
const lineBufs = {
stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })),
stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' }))
}
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString()))
child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
let settled = false
child.on('error', (err) => {
@@ -72,9 +67,8 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
settled = true
active.delete(id)
// Flush any trailing partial lines that never hit a newline.
for (const stream of ['stdout', 'stderr'] as const) {
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
}
lineBufs.stdout.flush()
lineBufs.stderr.flush()
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
// ships, so minimize-to-tray always has a tray to restore from.
let icon = nativeImage.createFromPath(getAppIconPath())
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
if (icon.isEmpty())
icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
if (icon.isEmpty()) return
tray = new Tray(icon)
+37 -13
View File
@@ -68,9 +68,10 @@ function updateDir(): string {
* an "upgrade" over the same shipped version.
*/
function parseVersion(v: string): number[] {
return v
.replace(/^v/i, '')
.split(/[-+]/)[0]
// split() always yields at least one element; `?? ''` only satisfies the type
// checker (noUncheckedIndexedAccess) for the [0] access.
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
return core
.split('.')
.map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n))
@@ -105,9 +106,28 @@ interface GiteaRelease {
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
* lowercase digest, or null if there's no standalone 64-char hex token (so a
* longer run like a sha512 digest is ignored rather than sliced).
*
* When `fileName` is given (the installer asset's name), a multi-line / combined
* checksum file is matched line-by-line and the hash on the line naming THIS
* asset wins so a `<asset>.sha256` that happens to list several files can't
* verify the installer against the wrong line's hash (B3). Falls back to the
* first standalone digest for bare single-hash files (no filename present).
*/
export function extractSha256(text: string): string | null {
const m = text.match(/\b[a-f0-9]{64}\b/i)
export function extractSha256(text: string, fileName?: string): string | null {
const hashRe = /\b[a-f0-9]{64}\b/i
if (fileName) {
const base = fileName.toLowerCase()
for (const line of text.split(/\r?\n/)) {
const m = line.match(hashRe)
if (!m) continue
// The filename is the last whitespace-delimited token on a sha256sum /
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
if (last.toLowerCase() === base) return m[0].toLowerCase()
}
}
const m = text.match(hashRe)
return m ? m[0].toLowerCase() : null
}
@@ -200,7 +220,9 @@ function fetchTrustedText(
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
return new Promise((resolve) => {
let settled = false
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
const done = (
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
): void => {
if (settled) return
settled = true
clearTimeout(timer)
@@ -261,7 +283,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
const sum = await fetchTrustedText(checksumUrl)
let expectedSha: string | null = null
if (sum.ok) {
expectedSha = extractSha256(sum.text)
expectedSha = extractSha256(sum.text, safeName)
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
} else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) {
@@ -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. */
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
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.' }
}
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 }
// Give the installer a beat to spawn, then quit so it can overwrite our files.
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
// Quit on the next tick so this IPC response reaches the renderer first; the
// launched installer is independent of our process and survives the quit.
setImmediate(() => app.quit())
return { ok: true }
} catch (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 { 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 { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import {
@@ -32,32 +34,24 @@ export function ensureManagedYtdlp(): void {
}
}
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process bundled-binary IPC path end to end.
*/
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
/** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
export async function getYtdlpVersion(): Promise<YtdlpVersionResult> {
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
return { ok: false, error: YTDLP_MISSING_MSG }
}
return new Promise((resolve) => {
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out running yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return
}
resolve({ ok: true, version: stdout.trim() })
})
})
const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS })
if (!r.ok) {
// Run stderr through the shared cleaner so yt-dlp's noisy output collapses to
// its trailing error line, consistent with download/probe/indexer (CC6).
const msg = r.timedOut
? 'Timed out running yt-dlp.'
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
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
* 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`.
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an
// arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a
// compromised renderer over IPC) must never be forwarded. (audit F1)
if (!isYtdlpUpdateChannel(channel)) {
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
return { ok: false, error: 'Unsupported update channel.' }
}
// 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()
if (!existsSync(ytdlpPath)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
return { ok: false, error: YTDLP_MISSING_MSG }
}
return new Promise((resolve) => {
execFile(
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() })
}
)
const r = await execFileAsync(ytdlpPath, ['--update-to', channel], {
timeout: YTDLP_UPDATE_TIMEOUT_MS
})
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 {
if (!enabled) return false
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
}
+47 -25
View File
@@ -31,7 +31,8 @@ import {
type ScheduledSyncStatus,
type TerminalEvent,
type TerminalRunResult,
type TaskbarProgress
type TaskbarProgress,
type PersistedQueueItem
} from '@shared/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)
},
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
@@ -69,23 +69,26 @@ const api = {
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
ipcRenderer.invoke(IpcChannels.downloadStart, opts),
cancelDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
cancelDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadCancel, id),
pauseDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadPause, id),
pauseDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadPause, id),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
chooseFolder: (current?: string): Promise<string | null> =>
ipcRenderer.invoke(IpcChannels.chooseFolder, current),
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
showInFolder: (path: string): Promise<void> =>
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
showInFolder: (path: string): Promise<string> =>
ipcRenderer.invoke(IpcChannels.showInFolder, path),
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),
setSettings: (partial: Partial<Settings>): Promise<Settings> =>
@@ -138,6 +141,13 @@ const api = {
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. */
exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport),
@@ -184,22 +194,23 @@ const api = {
reindexSource: (id: string): Promise<IndexSourceResult> =>
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
removeSource: (id: string): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
removeSource: (id: string): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourceRemove, id),
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
/** Persist that a media item has finished downloading (drives incremental sync). */
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
/** Persist that a media item has finished downloading (drives incremental sync).
* Named to match the main handler `setMediaItemDownloaded` (L92). */
setMediaItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
/** Toggle whether a source is watched for new uploads. */
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
/** Re-index all watched sources; resolves with the videos found new. */
syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
/** Re-index all watched sources; resolves with the videos found new. Named to
* match the main function `syncWatchedSources` (L92). */
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
/** Read / write the Windows daily-sync scheduled task. */
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
@@ -220,8 +231,7 @@ const api = {
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
cancelTerminal: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
cancelTerminal: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.terminalCancel, id),
/** Subscribe to live terminal output. Returns an unsubscribe function. */
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
@@ -230,9 +240,13 @@ const api = {
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
},
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */
setTaskbarProgress: (p: TaskbarProgress): void =>
ipcRenderer.send(IpcChannels.taskbarProgress, p)
/** Reflect overall queue progress on the Windows taskbar. */
setTaskbarProgress: (p: TaskbarProgress): Promise<void> =>
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
@@ -248,11 +262,19 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electron)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
// M30: a broken bridge causes the renderer to silently run in preview/mock mode.
// Notify the main process so it can show a hard error dialog — without this the
// failure is completely invisible to the user.
console.error('[AeroFetch preload] contextBridge failed:', error)
try {
ipcRenderer.send(IpcChannels.preloadBridgeFailure, String(error))
} catch {
/* if IPC itself is broken there is nothing more we can do */
}
}
} else {
// @ts-ignore (defined in index.d.ts)
// @ts-ignore window.electron is declared in index.d.ts
window.electron = electron
// @ts-ignore (defined in index.d.ts)
// @ts-ignore window.api is declared in index.d.ts
window.api = api
}
+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 { Sidebar, type TabValue } from './components/Sidebar'
import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView'
import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme'
import { SPACE } from './components/ui/tokens'
import { useSettings } from './store/settings'
import { useNav } from './store/nav'
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 { 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({
provider: {
@@ -27,7 +56,8 @@ const useStyles = makeStyles({
flexGrow: 1,
minWidth: 0,
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 accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const showTerminal = useSettings((s) => s.customCommandEnabled)
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)
// 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(() => {
function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
prePaletteRef.current = document.activeElement
setPaletteOpen((o) => !o)
} else if (e.ctrlKey && e.key === ',') {
e.preventDefault()
setTab('settings')
}
}
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
// 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(() => {
let lastFraction = -1
let lastMode = ''
let lastBadge = -1
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'
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)
return useDownloads.subscribe((st) => push(st.items))
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Wait for the download bar to mount after the tab switch, then focus.
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
// Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
// other pending items return from the previous session.
useEffect(() => {
useDownloads.getState().hydrate()
}, [])
// UX16: focus the URL field on launch so the user can paste + type immediately
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
// register its focuser (L118), matching the command palette's "New download" focus.
useEffect(() => {
if (useNav.getState().tab !== 'downloads') return
requestAnimationFrame(() => requestAnimationFrame(() => useNav.getState().focusUrlField()))
}, [])
// 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()
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
}, SCHEDULE_TICK_MS)
return () => clearInterval(tick)
}, [])
// Memoized so CommandPalette sees a stable array reference between renders --
// 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.
const [version, setVersion] = useState('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
}, [])
// Sidebar collapse, persisted across launches in localStorage.
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
)
const collapsed = useSettings((s) => s.sidebarCollapsed)
function toggleCollapsed(): void {
setCollapsed((c) => {
const next = !c
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
return next
})
updateSettings({ sidebarCollapsed: !collapsed })
}
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
@@ -136,21 +246,34 @@ function App(): React.JSX.Element {
version={version}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
showTerminal={showTerminal}
downloadCount={activeCount}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />}
<Suspense fallback={null}>
{tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />}
</Suspense>
</main>
{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>
</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,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
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 { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing'
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
export interface PaletteAction {
id: string
@@ -10,32 +12,31 @@ export interface PaletteAction {
}
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).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
zIndex: Z.overlay,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
backgroundColor: SCRIM
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
...shorthands.borderRadius(RADIUS.card),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
boxShadow: ELEVATION.overlay,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
@@ -91,11 +92,16 @@ export function CommandPalette({
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
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(() => {
inputRef.current?.focus()
@@ -103,6 +109,10 @@ export function CommandPalette({
useEffect(() => {
setSel(0)
}, [q])
useEffect(() => {
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [sel])
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') {
@@ -133,21 +143,33 @@ export function CommandPalette({
>
<input
ref={inputRef}
className={styles.input}
className={mergeClasses(styles.input, focus.focusRing)}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
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"
/>
<div className={styles.list}>
<div className={styles.list} ref={listRef} id={listId} role="listbox" aria-label="Commands">
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
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}
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)}
onClick={() => {
a.run()
@@ -157,7 +179,7 @@ export function CommandPalette({
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button>
</div>
))
)}
</div>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import {
import {
Field,
Input,
Switch,
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
sponsor: 'Sponsor',
intro: 'Intro / intermission',
outro: 'Endcards / credits',
outro: 'End cards / credits',
selfpromo: 'Self-promotion',
preview: 'Preview / recap',
filler: 'Filler / tangent',
@@ -83,6 +83,8 @@ const useStyles = makeStyles({
interface Props {
value: DownloadOptions
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
* 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()
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 (
<div className={styles.root}>
<div className={styles.grid}>
<Field label="Audio format" hint="For audio-only downloads.">
<Select
aria-label="Audio format"
value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/>
</Field>
<Field label="Video container" hint="Container for merged video.">
<Select
aria-label="Video container"
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</Field>
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
/>
</Field>
{kind !== 'video' && (
<Field label="Audio format" hint="For audio-only downloads.">
<Select
value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/>
</Field>
)}
{kind !== 'audio' && (
<Field
label="Video container"
hint="The file format used when video and audio are combined into one file."
>
<Select
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({
value: c,
label: VIDEO_CONTAINER_LABELS[c]
}))}
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</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>
<Field
label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
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
value={value.formatSort}
@@ -144,11 +158,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
/>
</Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
<Switch
checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
label={value.embedSubtitles ? 'On' : 'Off'}
/>
</Field>
{value.embedSubtitles && (
@@ -177,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Switch
checked={value.sponsorBlock}
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
label={value.sponsorBlock ? 'On' : 'Off'}
/>
</Field>
{value.sponsorBlock && (
<div className={styles.subGroup}>
<Field label="Action">
<Select
aria-label="SponsorBlock action"
value={value.sponsorBlockMode}
options={[
{ 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)}
/>
</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}>
{SPONSORBLOCK_CATEGORIES.map((cat) => (
<Checkbox
@@ -208,11 +227,13 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</div>
)}
<Field label="Embed chapters">
<Field
label="Embed chapters"
hint="Write chapter markers into the file. Only useful if the video has chapters."
>
<Switch
checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)}
label={value.embedChapters ? 'On' : 'Off'}
/>
</Field>
<Field
@@ -222,21 +243,40 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Switch
checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch
checked={value.embedMetadata}
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
label={value.embedMetadata ? 'On' : 'Off'}
/>
</Field>
<Field
label="Metadata overrides"
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
>
<div className={styles.subGroup}>
<Input
placeholder="Title"
value={value.metadataTitle ?? ''}
onChange={(_, d) => setOpt('metadataTitle', d.value)}
/>
<Input
placeholder="Artist"
value={value.metadataArtist ?? ''}
onChange={(_, d) => setOpt('metadataArtist', d.value)}
/>
<Input
placeholder="Album"
value={value.metadataAlbum ?? ''}
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
/>
</div>
</Field>
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
<Switch
checked={value.embedThumbnail}
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
label={value.embedThumbnail ? 'On' : 'Off'}
/>
</Field>
{value.embedThumbnail && (
@@ -254,7 +294,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Field
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}>
<Checkbox
+55 -28
View File
@@ -1,19 +1,28 @@
import {
import {
Subtitle2,
Body1,
Caption1,
Button,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats'
import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
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({
root: {
@@ -22,7 +31,8 @@ const useStyles = makeStyles({
// growing to thousands of rows.
display: 'flex',
flexDirection: 'column',
gap: '20px',
// One section gap on every screen (UI2).
gap: SPACE.section,
height: '100%'
},
queueHeader: {
@@ -56,47 +66,59 @@ const useStyles = makeStyles({
// min-height:0 lets this flex child shrink below its content height so it,
// not the page, owns the scrolling.
minHeight: 0
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '56px 16px',
color: tokens.colorNeutralForeground3,
textAlign: 'center'
}
})
export function DownloadsView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const cancelAll = useDownloads((s) => s.cancelAll)
const summary = summarizeQueue(items)
const summary = queueSummaryOf(items)
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
// The header count is the live queue (work not yet finished), not the whole
// list -- completed/canceled/error rows linger until "Clear finished" (L11).
const queueCount = items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
).length
// 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 (
<div className={styles.root}>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
<DownloadBar />
{summary.active && (
<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}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''}
{summary.etaLabel ? `~${summary.etaLabel} left` : ''}
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
</Caption1>
</div>
)}
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
@@ -108,6 +130,11 @@ export function DownloadsView(): React.JSX.Element {
Retry all failed ({summary.failed})
</Button>
)}
{cancelable > 0 && (
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
Cancel all ({cancelable})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
@@ -117,19 +144,19 @@ export function DownloadsView(): React.JSX.Element {
</div>
{items.length === 0 ? (
<div className={styles.empty}>
<ArrowDownloadRegular fontSize={40} />
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
</div>
<EmptyState
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
message="Nothing queued yet. Paste a URL above, then click Download."
/>
) : (
<VirtualList
items={items}
className={styles.listScroll}
gap={10}
overscan={6}
estimateSize={() => 100}
getKey={(item) => item.id}
renderItem={(item) => <QueueItem item={item} />}
estimateSize={estimateRowSize}
getKey={getItemKey}
renderItem={renderQueueRow}
/>
)}
</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 { Z } from './ui/tokens'
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
},
bubble: {
position: 'absolute',
zIndex: 1000,
zIndex: Z.tooltip,
visibility: 'hidden',
pointerEvents: 'none',
whiteSpace: 'nowrap',
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
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
placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end'
children: React.ReactNode
}
} & (
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
| { placement: 'left' | 'right'; align?: never }
)
export function Hint({
label,
+164 -70
View File
@@ -1,12 +1,12 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
Text,
Caption1,
Button,
Checkbox,
Input,
Body1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -22,6 +22,7 @@ import {
} from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { formatWhen } from '../datetime'
import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
@@ -29,12 +30,18 @@ import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
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({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px'
gap: SPACE.section
},
header: {
display: 'flex',
@@ -74,8 +81,8 @@ const useStyles = makeStyles({
},
thumb: {
flexShrink: 0,
width: '72px',
height: '44px',
width: `${THUMB_SM.w}px`,
height: `${THUMB_SM.h}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -87,27 +94,15 @@ const useStyles = makeStyles({
display: 'flex',
flexDirection: 'column'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
actions: {
display: 'flex',
gap: '4px',
flexShrink: 0
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
padding: '56px 16px',
textAlign: 'center'
flexShrink: 0,
// >=40px hit target for the icon-only row actions (W16).
'& button': {
minWidth: '40px',
minHeight: '40px'
}
},
emptyBadge: {
width: '56px',
@@ -115,11 +110,9 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
fontSize: '26px'
},
emptyHint: {
color: tokens.colorNeutralForeground3
borderRadius: tokens.borderRadiusCircular,
// Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
fontSize: `${ICON.section}px`
},
noMatches: {
padding: '32px 16px',
@@ -134,19 +127,11 @@ const KIND_FILTER_OPTIONS = [
{ value: 'audio', label: 'Audio' }
]
function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export function HistoryView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const isDark = useResolvedDark()
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
@@ -161,6 +146,8 @@ export function HistoryView(): React.JSX.Element {
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
const [selectMode, setSelectMode] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmClear, setConfirmClear] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
@@ -175,8 +162,31 @@ export function HistoryView(): React.JSX.Element {
})
}, [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 {
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 {
@@ -196,6 +206,7 @@ export function HistoryView(): React.JSX.Element {
function exitSelectMode(): void {
setSelectMode(false)
setSelected(new Set())
setConfirmDelete(false)
}
function deleteSelected(): void {
@@ -205,21 +216,27 @@ export function HistoryView(): React.JSX.Element {
if (entries.length === 0) {
return (
<div className={styles.empty}>
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
</div>
<Body1>No downloads yet.</Body1>
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<EmptyState
icon={
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
</div>
}
message="No downloads yet."
hint="Finished downloads will show up here."
/>
</div>
)
}
return (
<div className={styles.root}>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.header}>
{selectMode ? (
<>
@@ -228,18 +245,44 @@ export function HistoryView(): React.JSX.Element {
{allFilteredSelected ? 'Select none' : 'Select all'}
</Button>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
Cancel
</Button>
{confirmDelete ? (
<>
<Caption1 className={styles.count}>
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
>
Delete
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => setConfirmDelete(true)}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={exitSelectMode}
>
Cancel
</Button>
</>
)}
</>
) : (
<>
@@ -248,6 +291,7 @@ export function HistoryView(): React.JSX.Element {
</Caption1>
<Input
className={styles.search}
input={{ 'aria-label': 'Search history' }}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
@@ -270,9 +314,36 @@ export function HistoryView(): React.JSX.Element {
>
Select
</Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
{confirmClear ? (
<>
<Caption1 className={styles.count}>
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
clear()
setConfirmClear(false)
}}
>
Clear all
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmClear(true)}
>
Clear history
</Button>
)}
</>
)}
</div>
@@ -280,10 +351,33 @@ export function HistoryView(): React.JSX.Element {
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div className={styles.list}>
<div
className={styles.list}
onKeyDown={(e) => {
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
// select mode if needed). Scoped to the list, so it never hijacks
// Ctrl+A in the search field, which lives in the header above.
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
e.preventDefault()
setSelectMode(true)
setSelected(new Set(filtered.map((x) => x.id)))
}
}}
>
{filtered.map((h: HistoryEntry) => {
return (
<div key={h.id} className={styles.row}>
<div
key={h.id}
className={mergeClasses(styles.row, focus.focusRing)}
tabIndex={0}
onKeyDown={(e) => {
// Delete removes the focused row (not when a child button is focused).
if (e.key === 'Delete' && e.target === e.currentTarget) {
e.preventDefault()
remove(h.id)
}
}}
>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
@@ -298,11 +392,11 @@ export function HistoryView(): React.JSX.Element {
iconSize={22}
/>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
<Text className={text.title}>{h.title}</Text>
<Caption1 className={text.muted}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(' • ')}
.join(META_SEP)}
</Caption1>
</div>
<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 {
Subtitle2,
Body1,
Caption1,
Text,
Input,
@@ -15,9 +13,8 @@ import {
shorthands
} from '@fluentui/react-components'
import {
SearchRegular,
AddRegular,
ArrowSyncRegular,
ArrowClockwiseRegular,
DeleteRegular,
ArrowDownloadRegular,
ChevronDownRegular,
@@ -25,21 +22,30 @@ import {
AppsListRegular,
VideoClipMultipleRegular,
AlertRegular,
LibraryRegular,
LinkRegular,
DismissRegular
LibraryRegular
} 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 { useSettings } from '../store/settings'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
import { Hint } from './Hint'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
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. */
type ItemStatus = DownloadStatus | 'pending'
@@ -50,45 +56,28 @@ type ItemStatus = DownloadStatus | 'pending'
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
| { kind: 'header'; title: string; items: MediaItem[] }
| { kind: 'item'; item: MediaItem }
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100
const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending',
queued: 'Queued',
downloading: 'Downloading',
paused: 'Paused',
saved: 'Saved',
completed: 'Downloaded',
error: 'Failed',
canceled: 'Canceled'
// When a URL appears more than once in the queue ("Download anyway" duplicates),
// the library row should reflect the most meaningful state: a finished copy wins,
// then anything in flight, then terminal failures (M17).
const STATUS_PRIORITY: Record<DownloadStatus, number> = {
completed: 6,
downloading: 5,
queued: 4,
saved: 3,
paused: 2,
error: 1,
canceled: 0
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
addRow: { display: 'flex', gap: '8px' },
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: {
display: 'flex',
alignItems: 'center',
@@ -111,15 +100,6 @@ const useStyles = makeStyles({
fontSize: tokens.fontSizeBase200
},
error: { color: tokens.colorPaletteRedForeground1 },
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '56px 16px',
color: tokens.colorNeutralForeground3,
textAlign: 'center'
},
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
card: {
border: `1px solid ${tokens.colorNeutralStroke2}`,
@@ -132,6 +112,14 @@ const useStyles = makeStyles({
alignItems: 'center',
gap: '12px',
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',
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
@@ -145,7 +133,8 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: '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 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
@@ -158,7 +147,6 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
srcSub: { color: tokens.colorNeutralForeground3 },
detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px',
@@ -172,9 +160,22 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
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,
cursor: 'pointer',
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
textAlign: 'left',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
@@ -187,38 +188,12 @@ const useStyles = makeStyles({
},
plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: {
width: '60px',
height: '34px',
...shorthands.borderRadius(tokens.borderRadiusSmall)
width: `${THUMB_XS.w}px`,
height: `${THUMB_XS.h}px`,
// One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
},
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
}
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
})
/** 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
}
function relTime(ms?: number): string {
if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago`
return `${Math.round(hrs / 24)} d ago`
}
export function LibraryView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId)
@@ -271,10 +239,32 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('')
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
// 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 [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 [syncNote, setSyncNote] = useState<string | null>(null)
// 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.
useEffect(() => {
if (PREVIEW) return
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
window.api
.getScheduledSync()
.then((s) => setScheduled(s.enabled))
.catch(logError('getScheduledSync'))
}, [])
async function onCheckNew(): Promise<void> {
@@ -314,7 +307,13 @@ export function LibraryView(): React.JSX.Element {
// Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => {
const m = new Map<string, DownloadStatus>()
for (const d of downloadItems) m.set(d.url, d.status)
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
}, [downloadItems])
@@ -322,8 +321,7 @@ export function LibraryView(): React.JSX.Element {
const groups = useMemo(() => groupByPlaylist(items), [items])
// A group is shown when toggled open, or auto-expanded when it's the only group
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
const isGroupOpen = (title: string): boolean =>
groups.length === 1 || expandedGroups.has(title)
const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = []
@@ -360,7 +358,7 @@ export function LibraryView(): React.JSX.Element {
if (!u || indexing.active) return
const res = await indexSource(u)
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 {
@@ -385,6 +383,9 @@ export function LibraryView(): React.JSX.Element {
setSelected((prev) => {
const next = new Set(prev)
for (const it of groupItems) {
// Only actionable rows are selectable, so the selection count can never
// exceed what "Download N selected" will actually queue (M36).
if (!actionable(it)) continue
if (on) next.add(it.id)
else next.delete(it.id)
}
@@ -397,18 +398,18 @@ export function LibraryView(): React.JSX.Element {
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.
function downloadSelected(): void {
if (!selectedSourceId || selectedActionable.length === 0) return
const n = enqueueItems(selectedSourceId, selectedActionable)
const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
setSelected(new Set())
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function downloadPending(): void {
if (!selectedSourceId || pendingItems.length === 0) return
const n = enqueueItems(selectedSourceId, pendingItems)
const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
@@ -418,18 +419,11 @@ export function LibraryView(): React.JSX.Element {
if (!selectedSourceId) return
const toQueue = groupItems.filter(actionable)
if (toQueue.length === 0) return
const n = enqueueItems(selectedSourceId, toQueue)
const n = enqueueItems(selectedSourceId, toQueue, enqueueKind)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function pillClass(status: ItemStatus): string {
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
return styles.pill
}
// One row of the item list — a playlist header or a video — shared by the
// One row of the item list -- a playlist header or a video -- shared by the
// inline (small source) and virtualized (large source) render paths.
function rowKey(row: LibRow): string {
return row.kind === 'header' ? `h:${row.title}` : row.item.id
@@ -437,32 +431,27 @@ export function LibraryView(): React.JSX.Element {
function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') {
const allOn = row.items.every((it) => selected.has(it.id))
const open = isGroupOpen(row.title)
const groupActionable = row.items.filter(actionable).length
// "All on" is judged over the ACTIONABLE rows only -- downloaded rows aren't
// selectable, so they must not keep the group from reading as fully selected (M36).
const groupActionableItems = row.items.filter(actionable)
const groupActionable = groupActionableItems.length
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
return (
<div
className={styles.groupHead}
onClick={() => toggleGroupExpand(row.title)}
role="button"
tabIndex={0}
onKeyDown={(e) =>
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
}
aria-expanded={open}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={(e) => {
e.stopPropagation()
toggleGroup(row.items, !allOn)
}}
<div className={styles.groupHead}>
<button
type="button"
className={mergeClasses(styles.groupToggle, focus.focusRing)}
onClick={() => toggleGroupExpand(row.title)}
aria-expanded={open}
aria-label={`${row.title}, ${row.items.length} items`}
>
{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'}
</Button>
<Button
@@ -470,10 +459,7 @@ export function LibraryView(): React.JSX.Element {
appearance="subtle"
icon={<ArrowDownloadRegular />}
disabled={groupActionable === 0}
onClick={(e) => {
e.stopPropagation()
downloadGroup(row.items)
}}
onClick={() => downloadGroup(row.items)}
>
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
</Button>
@@ -486,6 +472,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
disabled={!actionable(it)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
@@ -496,32 +483,31 @@ export function LibraryView(): React.JSX.Element {
iconSize={16}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
<Text className={text.truncate}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</Text>
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
<StatusChip status={status} />
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Library</Subtitle2>
<Caption1 className={styles.sub}>
Index a channel or playlist once, then download it into organized folders.
</Caption1>
</div>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Library"
description="Add a channel or playlist once, then download its videos into organized folders."
/>
<div className={styles.addRow}>
<Input
className={styles.addInput}
input={{ 'aria-label': 'Channel or playlist URL' }}
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL…"
placeholder="Paste a channel or playlist URL to add it…"
size="large"
contentBefore={<LibraryRegular />}
disabled={indexing.active}
@@ -529,49 +515,51 @@ export function LibraryView(): React.JSX.Element {
<Button
size="large"
appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
onClick={onIndex}
disabled={!url.trim() || indexing.active}
>
Index
Add
</Button>
</div>
{clip.suggestion && (
<div className={styles.suggestion}>
<LinkRegular />
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1>
<Button
size="small"
appearance="primary"
onClick={() => {
const link = clip.accept()
if (link) setUrl(link)
}}
>
Use
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={clip.dismiss}
aria-label="Dismiss"
/>
</div>
<LinkSuggestion
prefix="Use copied link? "
link={clip.suggestion}
onAccept={() => {
const link = clip.accept()
if (link) setUrl(link)
}}
onDismiss={clip.dismiss}
dismissLabel="Dismiss suggested link"
/>
)}
<div className={styles.toolbar}>
<Button
size="small"
appearance="secondary"
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />}
onClick={onCheckNew}
disabled={syncing || watchedCount === 0}
<Hint
label={
watchedCount === 0
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
: 'Check your watched sources for new uploads'
}
placement="top"
align="start"
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
<Button
size="small"
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} />
<span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1>
@@ -595,7 +583,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Indexing…'}
{indexing.message ?? 'Adding…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
</div>
@@ -603,10 +591,10 @@ export function LibraryView(): React.JSX.Element {
{error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? (
<div className={styles.empty}>
<LibraryRegular fontSize={40} />
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
</div>
<EmptyState
icon={<LibraryRegular fontSize={ICON.hero} />}
message="No channels or playlists yet. Paste one above to add it."
/>
) : (
<div className={styles.list}>
{sources.map((src) => (
@@ -615,9 +603,7 @@ export function LibraryView(): React.JSX.Element {
styles={styles}
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() =>
selectSource(selectedSourceId === src.id ? null : src.id)
}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
@@ -627,14 +613,27 @@ export function LibraryView(): React.JSX.Element {
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)}
<Caption1 className={text.muted}>
{items.length} videos{META_SEP}
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
</Caption1>
<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 ? (
<>
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
<Button
size="small"
appearance="subtle"
onClick={() => setSelected(new Set())}
>
Clear
</Button>
<Button
@@ -673,19 +672,43 @@ export function LibraryView(): React.JSX.Element {
onClick={() => reindexSource(src.id)}
disabled={indexing.active}
>
Re-index
</Button>
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => removeSource(src.id)}
>
Remove
Refresh
</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>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
@@ -695,7 +718,7 @@ export function LibraryView(): React.JSX.Element {
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
@@ -730,15 +753,16 @@ function SourceCard({
onToggleExpand: () => void
children: React.ReactNode
}): React.JSX.Element {
const focus = useFocusStyles()
const text = useTextStyles()
return (
<div className={styles.card}>
<div
className={styles.cardHead}
<button
type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
aria-expanded={expanded}
aria-label={source.title}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
@@ -746,19 +770,23 @@ function SourceCard({
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<span className={styles.srcTitle}>{source.title}</span>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={styles.srcSub}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</div>
</button>
{expanded && children}
</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 {
const styles = useStyles()
const isDark = useResolvedDark()
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
const [failedSrc, setFailedSrc] = useState<string | null>(null)
const showImg = !!src && failedSrc !== src
@@ -53,13 +53,7 @@ export function MediaThumb({
style={{ backgroundColor: colors.bg, color: colors.fg }}
>
{showImg ? (
<img
className={styles.img}
src={src}
alt=""
loading="lazy"
onError={() => setFailedSrc(src ?? null)}
/>
<img className={styles.img} src={src} alt="" onError={() => setFailedSrc(src ?? null)} />
) : kind === 'audio' ? (
<MusicNote2Regular fontSize={iconSize} />
) : (
+85 -13
View File
@@ -6,6 +6,7 @@ import {
Field,
Button,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -14,9 +15,14 @@ import {
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
RocketRegular
RocketRegular,
FolderRegular,
MusicNote2Regular,
VideoClipRegular,
KeyboardRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens'
const useStyles = makeStyles({
root: {
@@ -26,13 +32,14 @@ const useStyles = makeStyles({
alignItems: 'center',
justifyContent: 'center',
overflowY: 'auto',
padding: '24px'
padding: SPACE.page
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
padding: '32px',
gap: SPACE.roomy,
// The welcome hero card (UI3 hero tier).
padding: SPACE.hero,
maxWidth: '460px',
width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
@@ -52,10 +59,45 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '26px'
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.section}px`
},
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: {
display: 'flex',
@@ -68,9 +110,9 @@ const useStyles = makeStyles({
gap: '10px'
},
tipIcon: {
fontSize: '18px',
fontSize: `${ICON.control}px`,
flexShrink: 0,
marginTop: '2px',
marginTop: SPACE.hairline,
color: tokens.colorCompoundBrandForeground1
}
})
@@ -78,7 +120,7 @@ const useStyles = makeStyles({
const TIPS: { icon: React.JSX.Element; text: string }[] = [
{
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 />,
@@ -87,12 +129,19 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
{
icon: <OptionsRegular />,
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 {
const styles = useStyles()
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 (
<div className={styles.root}>
@@ -101,7 +150,7 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2>Welcome to AeroFetch</Title2>
<Title2 as="h1">Welcome to AeroFetch</Title2>
</div>
<Body1>
@@ -111,10 +160,33 @@ export function Onboarding(): React.JSX.Element {
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
<strong>Documents\Audio</strong>. You can point each to a different folder any time
in Settings.
Videos and audio save to your Documents folders by default. Pick a different folder now,
or change it any time in Settings.
</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>
<div className={styles.tips}>
+119 -68
View File
@@ -1,12 +1,12 @@
import { memo } from 'react'
import { memo } from 'react'
import {
Text,
Caption1,
Button,
ProgressBar,
Badge,
Spinner,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -25,28 +25,38 @@ import {
ErrorCircleFilled,
EyeOffRegular
} 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 { fmtSchedule } from '../datetime'
import { fmtSpeed, fmtEta } from '@shared/format'
import { MediaThumb } from './MediaThumb'
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({
root: {
display: 'flex',
gap: '14px',
padding: '14px',
// List-item card: card padding + list-item radius (UI3/UI7).
padding: SPACE.section,
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
...shorthands.borderRadius(RADIUS.surface),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
thumb: {
flexShrink: 0,
width: '108px',
height: '64px',
width: `${THUMB_MD.w}px`,
height: `${THUMB_MD.h}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusLarge)
// One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
},
body: {
flexGrow: 1,
@@ -60,20 +70,11 @@ const useStyles = makeStyles({
alignItems: 'center',
gap: '8px'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
progressRow: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginTop: '4px'
marginTop: SPACE.xtight
},
progressBar: {
flexGrow: 1
@@ -88,63 +89,88 @@ const useStyles = makeStyles({
actions: {
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0
gap: SPACE.xtight,
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 {
return `${Math.round(progress * 100)}%`
}
function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}
export const QueueItem = memo(function QueueItem({
item
}: {
item: DownloadItem
}): React.JSX.Element {
const styles = useStyles()
const cancel = useDownloads((s) => s.cancel)
const pause = useDownloads((s) => s.pause)
const resume = useDownloads((s) => s.resume)
const prioritize = useDownloads((s) => s.prioritize)
const saveForLater = useDownloads((s) => s.saveForLater)
const queueNow = useDownloads((s) => s.queueNow)
const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile)
const showInFolder = useDownloads((s) => s.showInFolder)
const focus = useFocusStyles()
const text = useTextStyles()
// One shallow-compared selection instead of 10 separate subscriptions per row
// (L163). The actions are stable, so this never re-renders from the store.
const {
cancel,
pause,
resume,
prioritize,
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'
// 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 = [
item.channel,
item.durationLabel,
item.quality,
item.kind === 'audio' ? 'Audio' : 'Video',
item.sizeLabel
!sizeAlreadyInQuality ? item.sizeLabel : undefined
].filter(Boolean)
return (
<div className={styles.root}>
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
@@ -156,57 +182,82 @@ export const QueueItem = memo(function QueueItem({
<div className={styles.titleRow}>
{item.status === 'completed' && (
<CheckmarkCircleFilled
fontSize={16}
fontSize={ICON.inline}
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
/>
)}
{item.status === 'error' && (
<ErrorCircleFilled
fontSize={16}
fontSize={ICON.inline}
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
/>
)}
<Text className={styles.title}>{item.title}</Text>
<Badge appearance="tint" color={badge.color}>
{badge.label}
</Badge>
<Text className={text.title}>{item.title}</Text>
<StatusChip status={item.status} />
{item.incognito && (
<Hint label="Private not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
<Hint label="Private -- not saved to history" placement="top" align="start">
<EyeOffRegular
fontSize={ICON.inline}
style={{ color: tokens.colorNeutralForeground3 }}
/>
</Hint>
)}
</div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
<Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
{item.status === 'downloading' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
{/* Indeterminate when finishing (the second stream / merge reads as
"working" rather than a 0100% restart, SR7) or when yt-dlp can't
report a total size, so the bar never sits frozen at 0% (L137). */}
<ProgressBar
className={styles.progressBar}
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`}
/>
<Caption1 className={styles.stats}>
{pct(item.progress)}
{item.speed ? `${item.speed}` : ''}
{item.eta ? `${item.eta} left` : ''}
{item.finishing ? (
'Finishing…'
) : (
<>
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
{speedLabel ? `${META_SEP}${speedLabel}` : ''}
{etaLabel ? `${META_SEP}${etaLabel} left` : ''}
</>
)}
</Caption1>
</div>
)}
{item.status === 'queued' && (
<div className={styles.progressRow}>
<Spinner size="extra-tiny" />
<Spinner size="tiny" />
<Caption1 className={styles.stats}>Waiting to start</Caption1>
</div>
)}
{item.status === 'paused' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
<Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1>
<ProgressBar
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>
)}
{item.status === 'saved' && (
<Caption1 className={styles.stats}>
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
{item.scheduledFor
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
: 'Saved for later'}
</Caption1>
)}
+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
// 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
// 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").
// 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
@@ -27,6 +33,20 @@ const useStyles = makeStyles({
outline: 'none',
...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
className?: string
'aria-label'?: string
size?: 'medium' | 'large'
disabled?: boolean
}
export function Select({
@@ -48,15 +70,30 @@ export function Select({
options,
onChange,
className,
'aria-label': ariaLabel
'aria-label': ariaLabel,
size,
disabled
}: SelectProps): React.JSX.Element {
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 (
<select
className={mergeClasses(styles.select, className)}
{...fieldProps}
aria-label={ariaLabel}
className={mergeClasses(
styles.select,
size === 'large' && styles.large,
disabled && styles.disabled,
className
)}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel}
disabled={disabled}
>
{options.map((o) => (
<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 {
ArrowDownloadFilled,
ArrowDownloadRegular,
@@ -14,6 +21,11 @@ import {
} from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { MOTION, ICON } from './ui/tokens'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
@@ -27,7 +39,8 @@ const useStyles = makeStyles({
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
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: {
width: '60px',
@@ -41,24 +54,6 @@ const useStyles = makeStyles({
topBarCollapsed: {
justifyContent: 'center'
},
iconBtn: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
fontSize: '18px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
},
brand: {
display: 'flex',
alignItems: 'center',
@@ -79,7 +74,8 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px'
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
},
brandText: {
display: 'flex',
@@ -92,9 +88,6 @@ const useStyles = makeStyles({
lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1
},
caption: {
color: tokens.colorNeutralForeground3
},
nav: {
display: 'flex',
flexDirection: 'column',
@@ -102,6 +95,8 @@ const useStyles = makeStyles({
alignSelf: 'stretch'
},
navItem: {
// relative so the collapsed count badge can pin to the icon's corner.
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '11px',
@@ -119,6 +114,16 @@ const useStyles = makeStyles({
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: {
justifyContent: 'center',
padding: '9px 0'
@@ -132,47 +137,12 @@ const useStyles = makeStyles({
}
},
navIcon: {
fontSize: '18px',
fontSize: `${ICON.control}px`,
flexShrink: 0,
display: 'flex'
},
spacer: {
flexGrow: 1
},
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
themeGroup: {
alignSelf: 'stretch',
display: 'flex',
width: '100%',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
themeSeg: {
flex: 1,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
padding: '7px 4px',
fontSize: tokens.fontSizeBase200,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
themeSegActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
}
})
@@ -202,6 +172,10 @@ interface SidebarProps {
version: string
collapsed: boolean
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({
@@ -212,22 +186,27 @@ export function Sidebar({
onSetTheme,
version,
collapsed,
onToggleCollapsed
onToggleCollapsed,
showTerminal,
downloadCount
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const text = useTextStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system']
function cycleTheme(): void {
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
const next = order[(order.indexOf(theme) + 1) % order.length]
if (next) onSetTheme(next)
}
const themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={18} />
<DesktopRegular fontSize={ICON.control} />
) : isDark ? (
<WeatherMoonRegular fontSize={18} />
<WeatherMoonRegular fontSize={ICON.control} />
) : (
<WeatherSunnyRegular fontSize={18} />
<WeatherSunnyRegular fontSize={ICON.control} />
)
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
@@ -235,15 +214,12 @@ export function Sidebar({
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<button
type="button"
className={styles.iconBtn}
<IconButton
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
>
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
/>
</Hint>
</div>
@@ -254,13 +230,18 @@ export function Sidebar({
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
{/* Stable tagline so the caption never flips from text to a version
string once it loads (L66); the version shows on hover and in
Settings About. */}
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
Video downloader
</Caption1>
</div>
)}
</div>
<div className={styles.nav}>
{NAV.map((n) => {
{NAV.filter((n) => n.value !== 'terminal' || showTerminal).map((n) => {
const active = tab === n.value
const btn = (
<button
@@ -269,7 +250,8 @@ export function Sidebar({
className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive
active && styles.navItemActive,
focus.focusRing
)}
style={
active && !collapsed
@@ -278,10 +260,23 @@ export function Sidebar({
}
onClick={() => onTabChange(n.value)}
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>
{!collapsed && n.label}
{n.value === 'downloads' && downloadCount > 0 && (
<CounterBadge
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
count={downloadCount}
size="small"
color="brand"
aria-hidden
/>
)}
</button>
)
return collapsed ? (
@@ -298,34 +293,20 @@ export function Sidebar({
{collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<button
type="button"
className={styles.iconBtn}
<IconButton
icon={themeIcon}
onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`}
>
{themeIcon}
</button>
aria-label={`Change theme (currently ${themeLabel})`}
/>
</Hint>
) : (
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
{THEMES.map((t) => {
const on = theme === t.value
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={on}
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
onClick={() => onSetTheme(t.value)}
>
<span className={styles.navIcon}>{t.icon}</span>
{t.label}
</button>
)
})}
</div>
<SegmentedControl<ThemeMode>
fitted
value={theme}
options={THEMES}
onChange={onSetTheme}
ariaLabel="Theme"
/>
)}
</nav>
)
+39 -17
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import {
Button,
Field,
Input,
Textarea,
Caption1,
@@ -9,10 +10,18 @@ import {
tokens,
shorthands
} from '@fluentui/react-components'
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons'
import {
AddRegular,
EditRegular,
DeleteRegular,
SaveRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates'
import { newId } from '../id'
import { Hint } from './Hint'
import { EmptyState } from './ui/EmptyState'
const useStyles = makeStyles({
root: {
@@ -63,9 +72,6 @@ const useStyles = makeStyles({
display: 'flex',
gap: '8px',
justifyContent: 'flex-end'
},
empty: {
color: tokens.colorNeutralForeground3
}
})
@@ -79,10 +85,6 @@ interface Draft {
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
}
/**
* Inline (no-modal see the Hint/Select comments re: composited overlays
* flickering on this dev machine's GPU) CRUD list + add/edit form for
@@ -100,13 +102,24 @@ export function TemplateManager(): React.JSX.Element {
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 {
if (!draft) return
const name = draft.name.trim()
if (!name) return
if (!name || patternError()) return
const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId(),
id: draft.id ?? newId('tpl'),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
@@ -117,7 +130,7 @@ export function TemplateManager(): React.JSX.Element {
return (
<div className={styles.root}>
{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) => (
@@ -162,11 +175,16 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/>
<Input
value={draft.urlPattern}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
/>
<Field
validationState={patternError() ? 'error' : 'none'}
validationMessage={patternError()}
>
<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}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel
@@ -182,7 +200,11 @@ export function TemplateManager(): React.JSX.Element {
</div>
</div>
) : (
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
<Button
appearance="subtle"
icon={<AddRegular />}
onClick={() => setDraft({ ...BLANK_DRAFT })}
>
Add template
</Button>
)}
+71 -37
View File
@@ -1,28 +1,42 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Subtitle2,
Body1,
Caption1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { newId } from '../id'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
id: number
text: string
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({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
gate: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '8px',
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
@@ -48,24 +62,24 @@ const useStyles = makeStyles({
whiteSpace: 'pre-wrap',
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 },
stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 },
empty: { color: tokens.colorNeutralForeground3 }
sys: { color: tokens.colorNeutralForeground3 }
})
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
}
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main see src/main/terminal.ts).
* flag (enforced again in main -- see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const updateSettings = useSettings((s) => s.update)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
@@ -79,13 +93,15 @@ export function TerminalView(): React.JSX.Element {
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
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') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
setRunning(false)
runId.current = null
} 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)
runId.current = null
}
@@ -101,21 +117,21 @@ export function TerminalView(): React.JSX.Element {
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId()
const id = newId('t')
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
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)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false)
runId.current = null
})
@@ -126,29 +142,47 @@ export function TerminalView(): React.JSX.Element {
}
const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
kind === 'cmd'
? styles.cmd
: kind === 'stderr'
? styles.stderr
: kind === 'sys'
? styles.sys
: undefined
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Terminal</Subtitle2>
<Caption1 className={styles.sub}>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</Caption1>
</div>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Terminal"
description={
<>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</>
}
/>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on Run custom commands in Settings
Custom commands to use it.
</Body1>
<div className={styles.gate}>
<Body1>
The terminal runs the bundled yt-dlp with your own arguments part of custom commands,
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.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
@@ -164,7 +198,7 @@ export function TerminalView(): React.JSX.Element {
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
@@ -187,12 +221,12 @@ export function TerminalView(): React.JSX.Element {
</div>
</div>
<pre className={styles.log} ref={logRef}>
<pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
{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) => (
<div key={i} className={lineClass(l.kind)}>
lines.map((l) => (
<div key={l.id} className={lineClass(l.kind)}>
{l.text}
</div>
))
+24 -17
View File
@@ -42,28 +42,35 @@ export function VirtualList<T>({
estimateSize,
overscan,
gap,
getItemKey: (index) => getKey(items[index], index)
getItemKey: (index) => {
const it = items[index]
return it !== undefined ? getKey(it, index) : index
}
})
return (
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((vrow) => (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(items[vrow.index], vrow.index)}
</div>
))}
{virtualizer.getVirtualItems().map((vrow) => {
const item = items[vrow.index]
if (item === undefined) return null
return (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(item, vrow.index)}
</div>
)
})}
</div>
</div>
)
@@ -0,0 +1,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