169 Commits

Author SHA1 Message Date
debont80 e8f0b83a7e chore: license the project under MIT
Replace the placeholder "UNLICENSED" with the MIT License so the now-public
repo is genuinely free and open source. Adds a LICENSE file and documents the
app-vs-bundled-binary licensing split in the README (yt-dlp/FFmpeg/aria2 are
invoked as separate executables, so they remain under their own licenses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:09:52 -04:00
debont80 a7edde8a32 Merge pull request 'docs: add README with feature overview and screenshots' (#14) from docs/readme-screenshots into main 2026-07-10 13:18:22 -04:00
debont80 4204483792 docs: add README with feature overview and screenshots
Give the now-public repo a proper front page: pitch, grouped feature
list, light/dark screenshots of Downloads/Library/History/Settings,
download + build-from-source instructions, and a note on the bundled
yt-dlp/FFmpeg/aria2 licenses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:15:59 -04:00
debont80 94ef814fa7 chore: bump version to 0.7.2 2026-07-10 10:43:14 -04:00
debont80 78a168ca5c Merge pull request 'chore(config): point the updater at the now-public AeroFetch repo' (#13) from chore/consolidate-update-source into main
Reviewed-on: #13
2026-07-10 10:41:28 -04:00
debont80 3b9dd6788d chore(config): point the updater at the now-public AeroFetch repo
The source repo is public now, so the separate releases-only repo
(AeroFetch-releases) is no longer needed to keep the updater tokenless.
Retarget UPDATE_REPO from AeroFetch-releases back to AeroFetch and refresh the
config doc comment. The dedicated read-only baked-token service account
(aerofetch-updater) has been deleted and its token revoked, so nothing reads a
token any more; the .gitignore comment for the retired .update-token file is
updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:39:12 -04:00
debont80 ccee9b7d3b chore: bump version to 0.7.1 2026-07-09 15:50:14 -04:00
debont80 af9c285864 Merge pull request 'fix(sync): run watched sync when a scheduled --sync hits the running instance' (#12) from fix/scheduled-sync-running-instance into main
Reviewed-on: #12
2026-07-09 15:48:31 -04:00
debont80 87e16ca28e fix(sync): run watched sync when a scheduled --sync hits the running instance
The Windows daily-sync task launches `AeroFetch.exe --sync`, but the single-
instance lock routes that relaunch to the `second-instance` handler whenever
AeroFetch is already open. The handler only focused the window and looked for a
link -- it never checked `isSyncLaunch(argv)` -- so the sync never ran. The daily
auto-download of new watched-source uploads therefore did nothing for minimize-
to-tray / always-on users (and the relaunch stole window focus each day).

Add a `runWatchedSync` main->renderer push: on a `--sync` relaunch the handler
now nudges the renderer to run syncWatched() instead of focusing, so the daily
sync fires whether AeroFetch was closed or already running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:45:35 -04:00
debont80 f3e10b100d chore: bump version to 0.7.0 2026-07-08 06:01:17 -04:00
debont80 6db27b946f Merge pull request 'feat(setup): fetch ffmpeg on first run instead of bundling it' (#11) from feature/ffmpeg-first-run-download into main
Reviewed-on: #11
2026-07-08 05:42:11 -04:00
debont80 eb53de2ea5 feat(setup): fetch ffmpeg on first run instead of bundling it
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe
(the bulk of its size). On first run they are downloaded from a pinned
upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and
unpacked with the OS tar.exe into the managed userData/bin dir -- the
same place as the self-updating yt-dlp, so they survive app updates and
portable re-extraction. A hard onboarding gate blocks the app until they
are present, with a "locate existing ffmpeg" folder-picker fallback for
offline machines and a Repair action in Settings > About.

The updater's streaming/checksum/redirect/idle-timeout download loop is
extracted to lib/verifiedDownload.streamVerifiedFile and shared by both
the app-installer download and the ffmpeg fetch; the updater's public
behaviour and error strings are unchanged (its boundary tests still pass).
No new npm dependency: extraction uses the System32 bsdtar resolved by
absolute path (audit F3).

Note: this commit also carries pre-existing, in-progress aria2c/network
and updater-token work that was already uncommitted in the working tree
and is entangled with the above in shared files (updater.ts, ipc.ts,
preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network
files and aria2c.exe). It was not cleanly separable by path, so it is
included here rather than split out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:02:13 -04:00
debont80 cb25262a2d feat(library): add per-row "Download new" for watched sources
Queuing a watched channel's not-yet-downloaded videos previously meant
expanding the source and using its "Download N pending" button. Watched
source rows now carry a one-click "Download new" that loads the source's
items (even while collapsed) and queues everything not already
downloaded or in the queue, using the source's resolved kind/profile.

Extracts the shared, tested selectUndownloaded selector (also now backs
the expanded panel's pending list) and adds ensureSourceItems to load a
collapsed source's items without expanding it.
2026-07-05 17:49:39 -04:00
debont80 27cae28e12 feat(diagnostics): persist full stderr detail behind a "Show details" toggle
describeDownloadError condenses a postprocessing failure to one line for
the queue/notification, discarding the rest of yt-dlp's stderr. The
persisted error log now also keeps the fuller non-noise transcript
(stderrDetail) as an optional `detail` field, and the Diagnostics card
gets a collapsible "Show details" expander per row so a failure like
"Postprocessing: Conversion failed!" can be inspected -- and copied
into a bug report -- without re-running the download from a terminal.
2026-07-05 14:56:09 -04:00
debont80 6cb9c5ca47 fix(downloads): re-resolve options on retry instead of replaying stale ones
A queued item freezes its post-processing options at enqueue time, so
retrying a failed download replayed whatever was in effect when it was
first added -- a settings change made afterward (e.g. turning
SponsorBlock-remove off, which forces a slow, failure-prone software
re-encode) never took effect on retry.

retry/retryAll now re-resolve a source-tied item's options from the
current source profile / global settings (the same precedence enqueue
uses) before requeuing it. Manual pastes with no source keep their
explicit per-download options untouched, and this is deliberately
distinct from the history re-download path, which keeps frozen options
on purpose to reproduce an identical file.
2026-07-05 14:39:45 -04:00
debont80 5de4b40c1d fix(download): name the failing step in postprocessing errors
yt-dlp collapses an ffmpeg postprocessing failure to a bare
"Postprocessing: Conversion failed!" with no indication of which step
died, and without --verbose the underlying ffmpeg reason is never
emitted at all. describeDownloadError() appends the last non-noise
trailing lines (e.g. the [ModifyChapters] SponsorBlock re-encode) so
the error log and completion notification point at the actual cause
instead of a dead end.
2026-07-05 14:39:29 -04:00
debont80 1be2708d15 chore: bump version to 0.6.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:20:39 -04:00
debont80 037ea2da32 feat(updater): bake a read-only update token into the build
The release repo is private, so the auto-updater previously required each
user to paste a Gitea token into Settings before it could see or download
updates. Inject a read-only token at build time (AEROFETCH_UPDATE_TOKEN env
var or a gitignored .update-token file) via electron.vite's `define`, and
fall back to it in authHeader() when the user hasn't set their own. The token
value lives only in the built bundle, never in source or git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:20:21 -04:00
debont80 48ce591702 chore: bump version to 0.6.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:55:40 -04:00
debont80 e29374dcfa docs: scrub dangling references to deleted roadmap docs
The ROADMAP/ROADMAP-PINCHFLAT docs were removed; this drops the now-dead
"see ROADMAP-PINCHFLAT.md" breadcrumbs (and one "the roadmap" mention)
from source comments, preserving the surrounding Pinchflat-style context.
Comment-only; typecheck (node+web) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:16:32 -04:00
debont80 ff9077989f docs: remove completed roadmap + release-gate docs
The ROADMAP files are fully shipped/retrospective and the smoke-test and
signing guides are retired at the maintainer's request. resources/bin/README.md
is kept (documents bundled-binary setup + license obligations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:13:57 -04:00
debont80 6cf6c0ead6 Merge origin/main (PR #10) into local main 2026-07-02 23:04:21 -04:00
debont80 e7fa602d73 Merge docs/code-audit-stale-deferred — retire completed CODE-AUDIT.md 2026-07-02 23:02:24 -04:00
debont80 2988404dcf docs(audit): remove completed CODE-AUDIT.md
The audit is fully closed (0 open / 372 done); all findings shipped to main across Batches 6-28. Removing the 2,558-line tracking doc now that it holds no open items. The preview-probe harness (.claude/launch.json) is retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:02:01 -04:00
debont80 6812939848 Merge pull request 'docs(audit): fix stale Deferred entries for metadata editing + PO-token auto-mint' (#10) from docs/code-audit-stale-deferred into main
Reviewed-on: #10
2026-07-02 17:10:10 -04:00
debont80 3623418fe3 docs(audit): fix stale Deferred entries for metadata editing + PO-token auto-mint
Both shipped (ROADMAP.md Phase L/P) but CODE-AUDIT.md's Deferred section
still listed them as pending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 17:08:20 -04:00
debont80 d8df5ea7c8 Merge pull request 'feat(playlist): exact-format-per-item selection' (#9) from feat/playlist-per-item-format into main
Reviewed-on: #9
2026-07-02 17:06:44 -04:00
debont80 15c2229539 fix(playlist): drop stale per-item formats on re-probe
Peer-review of the exact-format-per-item feature: re-clicking "Preview
formats" on an already-loaded playlist reset info/playlist but left
itemFormats/itemFormatOptions/formatExpanded keyed by position, so a
stale exact-format pick could silently reattach to a different video
landing at the same index in the new probe result and flow into
addPlaylist(). Extracted resetPlaylistItemState() and call it from
both clearProbe() and fetchFormats().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 17:05:29 -04:00
debont80 4c2b54a599 feat(playlist): exact-format-per-item selection
Each video row in the playlist panel gets an exact-format button that
lazily probes just that entry's URL (reusing the existing single-video
probe IPC, no new main-process code) and shows a native Select of its
real formats. The choice flows through addMany -> formatId per queue
item. Probing is on-demand per row, never all up front, since a
playlist can hold thousands of entries and per-entry probes are
rate-limited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 16:55:58 -04:00
debont80 5c88d9c8e0 feat(network): request-throttle controls to avoid YouTube rate-limiting
Re-implements the sleepRequests/sleepInterval/maxSleepInterval feature
(--sleep-requests / --sleep-interval / --max-sleep-interval) from the
stale feat/binary-management-and-library-scale branch, ported onto
current main's structure (zod settingsSchema, decomposed NetworkCard).
All default to 0/off, preserving current behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:34:34 -04:00
debont80 61d5261e33 docs: flip stale PINCHFLAT output-path checkbox (implemented Batch 26)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:16:45 -04:00
debont80 a8dc89d6fc Merge Batch 28 — Retention & quality-upgrade 2026-07-02 16:14:24 -04:00
debont80 0956bd536a feat(pinchflat): Retention & quality-upgrade per Library source
On-demand maintenance panel (SourceRetention) on the source detail —
not a persisted schedule (roadmap's niche-for-desktop scope).

Prune: keep the newest N downloaded files, delete the rest's files
(download archive prevents silent re-download); dry-run count + two-step
confirm before any deletion. Upgrade: re-enqueue items whose recorded
downloadedQuality ranks below the source's current target (profile or
global). Selection is pure + injected-now in @shared/retention.ts
(selectPruneCandidates/selectUpgradeCandidates + qualityRank), unit-
tested; pruneMediaItems (main) deletes best-effort and clears state.
MediaItem.downloadedQuality now recorded through the completion path.
Live-verified the panel renders. 366 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:14:23 -04:00
debont80 15cc96dd10 Merge Batch 27 — Media Profiles 2026-07-02 16:02:09 -04:00
debont80 ed1daf8bef feat(pinchflat): Media Profiles — named download presets per Library source
A MediaProfile bundles { kind, quality, options, outputTemplate, extraArgs }
that a Source points at via Source.profileId, overriding the global defaults
field-by-field for that source's downloads.

Backend mirrors CommandTemplate: profiles.ts (createJsonStore, PROFILES_MAX,
isProfileLike + sanitize), CRUD IPC (profiles:list/save/remove) + preload +
useProfiles store; Source.profileId + sources:set-profile. Pure resolution
in @shared/profileResolve.ts (profile → global, per field; unit-tested).
Applied in the sources-store enqueueItems; a per-download collectionOutput-
Template override threads to buildArgs so a profile's outputTemplate wins.

UI: ProfileManager (mirrors TemplateManager) in a new Settings → Advanced →
Media profiles card, plus a per-source Profile picker in the Library source
detail. Both live-verified in the preview; 354 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:02:07 -04:00
debont80 a02953f6d1 Merge Batch 26 — roadmap surfacing + output-path setting 2026-07-02 15:44:44 -04:00
debont80 42b7b2e01f feat(roadmap): surface stale checkboxes + collection output-path setting
Command preview + incognito toggle were already wired end-to-end in the
DownloadBar (Show command button, Incognito checkbox) — flipped the two
stale ROADMAP.md checkboxes with notes; verified live in the preview.

Output-path setting (PINCHFLAT): new Settings.collectionOutputTemplate — a
raw yt-dlp -o template overriding the default Channel/Playlist/NNN-Title
layout for Library downloads (empty = default). collectionOutputTemplate()
takes an optional custom template (joined under outDir, left for yt-dlp to
expand); download.ts passes the setting; zod schema validates it as a safe
relative template; Settings -> Filenames field. Tests in buildArgs +
settingsSchema; live-verified the field renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:44 -04:00
debont80 72feaa62f8 Merge Batch 25 — project organization (CC12, CC7, CC10) 2026-07-02 15:37:28 -04:00
debont80 9134e7d216 feat(audit): Batch 25 — project reorg (CC12), leading DI args (CC7), CC10 by-design
CC12: renderer views/ (5 screens + Onboarding + settings cards) +
lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats);
main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved
history; all src+test imports updated. Build emits view chunks from views/,
344 tests + typecheck green, live probe rendered all screens.
CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url),
indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url).
CC10: closed by-design — jsonStore backs all records; settings stay in
electron-store for DPAPI secret encryption (a deliberate two-store split).
Also closes the UI24/W4 context-menu cross-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:37:28 -04:00
debont80 f45acb645b Merge Batch 24 — zod validation layer (CC9) 2026-07-02 12:41:01 -04:00
debont80 8f6b2a761b feat(audit): Batch 24 — one validate-and-coerce schema layer for settings (CC9)
zod field schemas in settingsSchema.ts replace setSettings' bespoke
per-key switch: parse → skip-on-fail → store, side effects (secret
encryption, launch-at-startup sync) kept in settings.ts. sanitizeOptions
extracted to settingsOptions.ts to break the import cycle; backup import
flows through the same schema. Field-by-field parity pinned in
test/settingsSchema.test.ts (15 tests); full suite unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:41:00 -04:00
debont80 6abed2064d Merge Batch 23 — naming & config (CC1, CC11) 2026-07-02 12:34:05 -04:00
debont80 134c6a167c feat(audit): Batch 23 — settings key renames with migration (CC1) + config.ts (CC11)
CC1: customCommandEnabled→enableCustomCommands, downloadArchive→
useDownloadArchive, clipboardWatch→watchClipboard, sidebarCollapsed→
isSidebarCollapsed, hardwareAcceleration→useHardwareAcceleration,
videoDir/audioDir→videoFolder/audioFolder (+ chooseDir/clearDir store
actions → chooseFolder/clearFolder). Rename map in settingsMigration.ts
(pure, unit-tested incl. pre-rename backup fixture), applied as an
idempotent on-disk shim at store open and on backup import so old
settings.json and old backups both keep working.

CC11: update host/owner/repo + release API moved to src/main/config.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:34:05 -04:00
debont80 545cfeed5e Merge Batch 22 — perf close-out 2026-07-02 12:25:22 -04:00
debont80 384870ee4a feat(audit): Batch 22 — perf close-out (PERF3 benchmarked, PERF6/L162 by-design)
PERF3: pumpBench.test.ts pins pump()'s pipeline cost — 0.43ms @5k items,
1.47ms @20k (median) — negligible at a few events/sec, so the
count+pointer rewrite of the core queue is declined as risk without
reward; the benchmark stays as a 5ms regression tripwire.
PERF6/L162: closed as documented-by-design (virtualization bounds
mounted thumbs; a theme prop conflicts with PERF5's stable renderer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:25:21 -04:00
debont80 ea52735b12 docs(audit): check off CL4 + CC5 (Batch 21)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:21:57 -04:00
debont80 e9d41b00ea Merge Batch 21 — updater refactor (CL4, CC5) 2026-07-02 12:21:29 -04:00
debont80 34fbf36610 feat(audit): Batch 21 — updater refactor under behavior pins (CL4, closes CC5)
14 pinning tests lock downloadAppUpdate's security invariants (redirect
re-validation per hop, checksum refusal paths, SHA-256 verify + cleanup,
truncation, idle-timeout, cancel in both phases, progress) with a
scripted net.request fake; then the nested streaming block is extracted
verbatim into streamInstallerToFile. downloadAppUpdate is now a linear
pre-flight; B6 cancel-slot ownership stays with its module state via
onCancelReady/onSettled. Pins green before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:21:28 -04:00
debont80 d1e3168449 Merge Batch 20 — Windows/main-process (UI23, W12, W15, L131) 2026-07-02 12:15:52 -04:00
debont80 3b0d668f6c feat(audit): Batch 20 — Windows/main-process (UI23, W12, W15, L131)
UI23: cookie sign-in + PO-token windows open with the app's resolved
theme background (passed from the IPC layer), no more white flash.
W12: multi-resolution fallback tray icon (16/24/32/48 via
addRepresentation dataURL, embedded in trayFallbackIcons.ts); decode +
scale factors verified in an Electron probe.
W15: hardwareAcceleration setting (default false = software rendering);
index.ts gates disableHardwareAcceleration on it after the portable
redirect; Appearance card switch with restart note; boot path verified.
L131: resolved by-design — clearing the queue acknowledges failures;
flashFrame (W10) covers attention, Diagnostics persists the record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:15:51 -04:00
debont80 f54d9a1965 docs(audit): check off UX7/UX8/UX23 (Batch 19)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:01:15 -04:00
debont80 fc2db1e1b6 Merge Batch 19 — Settings IA (UX7, UX8, UX23) 2026-07-02 12:00:47 -04:00
debont80 a5885739d2 feat(audit): Batch 19 — Settings information architecture (UX7, UX8, UX23)
UX7: 11 cards grouped into five sections (Downloads, Appearance,
Network & accounts, Advanced, App & maintenance) with section headings
and a jump nav under the search box; search hides empty sections and
the nav; heading hierarchy corrected (h1 page, h2 section, h3 card).
UX8: post-processing card co-located under the Downloads card and
renamed 'Post-processing' with a defaults-vs-advanced hint.
UX23: closed as stale duplicate of the already-fixed L71 (editable,
validated, reconciled folder inputs) — verified in the preview probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:00:47 -04:00
debont80 93f0091104 Merge Batch 18 — watched visual pass 2026-07-02 11:55:45 -04:00
debont80 cdb6eea437 feat(audit): Batch 18 — watched visual pass (UI3, UI12, UI16, UI21, UX18, L109, UX26)
UI3: remaining list-row/control paddings on the SPACE scale (History row,
Downloads summary, Library head/detail, Terminal gate/log, template rows).
UI21: active-nav rail now shows collapsed too (moved into navItemActive).
UX18: stable 'Quality' label + brand '• fetched' tag instead of the
label/control morph after Fetch.
L109/UX26: skeleton lines for the About yt-dlp/ffmpeg boot load; skeleton
cards in Settings until the store's loaded flag flips.
UI12/UI16: deferred eyeball deltas verified via Electron-probe screenshots
(light+dark, collapsed sidebar, fetch flow); UI16 convention codified in
ui/tokens.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:55:45 -04:00
debont80 747cecd1e0 Merge Batch 17 — layout & virtualization (L161, L104) 2026-07-02 11:43:36 -04:00
debont80 17d42ed4fe feat(audit): Batch 17 — screen scroll contract + History virtualization (L161, L104)
L161: <main> is now a non-scrolling flex slot; each screen owns its scroll
via shared shells in ui/Screen.tsx — 'page' (Library/Settings/empty History
scroll the shell) and 'fill' (Downloads/Terminal/History scroll their own
list/log, shell scrolls only as short-window fallback). height:100%
couplings replaced with flexGrow+minHeight:0; list regions floored at
160px so they can't collapse on short windows.

L104: HistoryView renders through VirtualList; Ctrl+A re-homed onto a
wrapper around the scroller, per-row Delete unchanged on rows.

Verified with an Electron probe against the vite preview at 1100x720 and
800x520 (probe metrics + screenshots): one active scroller per screen,
main/body never scroll, floors hold. Adds .claude/launch.json for the
preview server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:43:34 -04:00
debont80 dbbb0c70f9 chore(format): endOfLine auto so format:check is stable under git autocrlf
Prettier's LF default fought git's CRLF checkout on Windows, failing
format:check on every file with no real diff. 'auto' keeps whatever the
checkout uses; content style is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:13:07 -04:00
debont80 71b88e2304 Merge Batch 16 — store discipline (L142, L93, CC13, CC14) 2026-07-02 11:10:58 -04:00
debont80 2ef19ead71 feat(audit): Batch 16 — store discipline (L142 reconciliation, L93 view-model hooks)
L142 (+CC14): keyed latest-wins reconciler (lib/reconcile.ts, unit-tested)
applies main's authoritative IPC returns in the history/templates/sources
stores, extending the M34 settings pattern; loadSources shares the key so
a slow list read can't clobber a newer mutation result.

L93 (+CC13): settings cards, TerminalView, and LibraryView now reach IPC
only through colocated view-model hooks (useAboutCard/useBackupCard/
useCookiesCard/useSoftwareUpdateCard/useNetworkCard/useTerminalRun) or
store actions (openHighContrastSettings, scheduled-sync on sources) —
no window.api left in components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:10:49 -04:00
debont80 96800d5a17 feat(audit): L51 + L64 — daily-sync time picker + aria2c connection tuning
Two small self-contained features from the audit's deferred list:

- L51: the Task Scheduler daily sync is no longer pinned to 09:00. New
  Settings.syncTime (24h HH:MM) with a native time input beside the Library
  "Daily sync" switch — persists on change, re-registers the task on blur
  (schtasks /Create /F overwrite is the time-change mechanism). The value
  reaches the schtasks /ST argv, so a shared isValidSyncTime guards it at
  both the settings write and in schedule.ts (unit-tested incl.
  injection-shaped input). Live-verified against the machine's real task.

- L64: aria2c connection count (-x/-s) is user-tunable. New
  Settings.aria2cConnections (1-16, aria2c's own ceiling) exposed as a
  SpinButton in Settings -> Network while the aria2c toggle is on.
  ARIA2C_ARGS became the pure aria2cArgs(n?) with a defensive clamp;
  threaded through AccessOptions. The -k 1M split floor stays fixed — a
  free-text field would bypass the custom-command consent gate.

typecheck + 309 tests + eslint + production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:57:20 -04:00
debont80 e19f988c72 feat(audit): Batch 15 live-verify — download-engine lifecycle (R4, L65, L139, B2, B6, L143)
The watched live session for the six deferred lifecycle items, each verified
against real yt-dlp/Electron behavior on Windows:

- R4: cancel now deletes the download's orphaned partials. The engine captures
  the final output path via a new `--print before_dl:dest|%(filename)s`
  (empirically %(filepath)s is still 'NA' that early) and the close handler
  sweeps only unambiguous intermediates (.part / .part-Frag* / .ytdl /
  <stem>.fNNN.<ext>) via the pure, unit-tested lib/partials.ts — never a bare
  <stem>.<ext> or another download's files. Live-verified with decoys.
- L65: verified-acceptable, no code change — a taskkill /F pause leaves the
  .part byte-intact and yt-dlp --continue resumes at the exact byte offset.
- L139: spawn↔self-update interlock (new ytdlpLock.ts). The updater refuses
  while any yt-dlp spawn is live; download/terminal IPC handlers hold (await)
  behind an in-progress exe rewrite instead of failing.
- B2: source indexing is cancellable — AbortSignal through the probe walk
  (kills the in-flight child, live-verified via tasklist), sources:index-cancel
  IPC, and a Cancel button in the Library add-source row.
- B6: app-update download is cancellable — app:update-cancel routes through
  the existing finish() teardown (abort + destroy + unlink, live-verified on
  Electron net.request); Cancel button under the update progress bar. The
  checksum phase is cancelable too.
- L143: closing the window mid-download (non-tray mode) now offers a native
  tray-independent "Quit anyway / Keep downloading" dialog, replacing the
  passive "use the tray to quit" notification.

Peer-review pass caught and fixed: a non-reentrant update lock (concurrent
begin clobbered the settle promise), a dead Cancel window during the B6
checksum fetch + a canceller slot-ownership bug (L140 shape), and a
persist-after-cancel hole in the index walk.

typecheck + 304 tests + eslint + production build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:36:22 -04:00
debont80 b3e5e4f309 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>
2026-07-01 18:35:40 -04:00
debont80 a2c8992650 refactor(audit): peer-review fixes — mergeClasses + import placement
Self-review of the Batches 9-15 changes surfaced two style inconsistencies (no
behavior change):
- Toaster.tsx: combine the icon + tone classes with mergeClasses instead of a
  template-literal string, matching how the rest of the codebase composes Griffel
  classes.
- App.tsx: move the PERF8 React.lazy() view declarations below the import block
  (they were interleaved between import statements).

Verified: typecheck (node+web) + 279 tests + eslint + production build green
(code-split chunks intact); touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:00:21 -04:00
debont80 4d1c33a081 feat(audit): Batch 15 — download-engine lifecycle (safe part; rest live-verify)
This is the batch the run reserves for a WATCHED LIVE SESSION, so only the safe,
non-file-deleting item is implemented here; the rest are staged with per-item
live-verify rationale in CODE-AUDIT.md.

Landed:
- L157: removeSource cancels its in-flight downloads. removeSource emits a
  `sourceRemoved` event on the coordinator bus (C2); the downloads store cancels
  every active (downloading/queued) item whose mediaItemId starts with
  `${sourceId}:` — MediaItem ids are `${sourceId}:${videoId}` (indexerCore), so the
  prefix reliably identifies the source's downloads. Reuses the proven cancel()
  path (process kill + pump). Type/lint/test-green; process-kill worth a live confirm.

Resolved by prior work:
- UX11: M4 persists the queue, so a scheduled item fires on next launch once due —
  no longer "never fires if quit".
- UX20: L68 resets notifiedBackground on window show, so every close-to-tray
  re-fires the "still running in the tray" notice, not just the first.

Deferred to the watched live pass (each needs a real download to verify):
R4 (delete .part on cancel — file-deletion data-safety), L65 (graceful Ctrl-C
pause), L139 (spawn↔auto-update interlock — timing race), B2 (index AbortSignal +
Cancel), B6 (app-update cancel — ties to CL4's deferred updater), L143 (in-window
quit-anyway).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:41:48 -04:00
debont80 0f784847a3 feat(audit): Batch 14 — perf (code-split + seed DCE) + first-run default
- PERF8: code-split the non-default tabs. Library/History/Terminal/Settings are
  React.lazy chunks behind a Suspense; Downloads (the launch tab) stays eager. The
  production build now emits separate SettingsView (~90kB), LibraryView (~29kB),
  HistoryView (~16kB), TerminalView (~8kB) chunks — ~140kB out of the initial
  bundle. The stores these views use are imported eagerly elsewhere, so lazy-
  loading the views never delays store startup/persistence (per the C2 note).
- L128: gate the three preview seed arrays (downloadSeed / sources seeds / history
  seed) on `import.meta.env.DEV && PREVIEW`. In the packaged build DEV is
  statically false, so Rollup constant-folds and dead-code-eliminates the seed
  literals; the browser dev preview (DEV true) still gets them.
- SR4: clipboardWatch now defaults false for new installs (privacy — no clipboard
  read on focus until opted in). Existing installs keep their saved value
  (electron-store fills only missing keys); the onboarding tip points to the
  setting. NOTE: this flips a signature convenience off-by-default for new users —
  flagged for the maintainer, trivially reversible.

Deferred with rationale (CODE-AUDIT.md): PERF3 (core-queue micro-opt on a bounded
list, risky unattended), PERF6/L162 (bounded by virtualization + conflicts with
PERF5's hoisted renderItem), SR5 (MP3 = deliberate compatibility default; the label
bug was M18), W12 (fallback-of-a-fallback icon), W15 (GPU software-rendering
workaround — external driver dependency), L104 (History virtualization needs the
L161 layout rework + live verification; bounded by the 500-cap).

Verified: typecheck (node+web) + 279 tests + eslint + production build green
(code-split chunks confirmed in output); touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:35:40 -04:00
debont80 71f0b2b68f feat(audit): Batch 13 — copy polish + misc-L cleanup
Contained fixes:
- L158: drag-highlight flicker. useDownloadBar tracks a dragDepth counter —
  onDragEnter increments, onDragLeave decrements, the highlight clears only when
  depth returns to 0 (the drag truly left the card). onDragOver just
  preventDefaults; onDrop resets. No more blink when crossing child elements.
- L92: preload method names aligned with main — markSourceItemDownloaded →
  setMediaItemDownloaded, syncSources → syncWatchedSources (callers + mock
  updated; compiler-enforced via the derived Api type).
- L149: the three-sentence "Keep running in the tray" hint is now one line.
- L171: one MOCK_CURRENT_VERSION backs both the preview mock's getAppVersion and
  checkForAppUpdate.currentVersion, so they agree.
- L5 (representative): the About card's repeated monospace inline styles → shared
  mono/monoBlock/monoPre classes; convention set (makeStyles for static, inline
  style only for data-driven values).

Resolved by verification/convention (CODE-AUDIT.md): L21 (moot after the H1
SettingsView decomposition), L31 (dual theme switcher is width-driven), L130
(lowercase fragments vs shown-raw sentences), L152 (search sizes are role-
appropriate), UI22 (brand/entity/section icon-emphasis set now defined).

Deferred with rationale: L51/L64 (features), L93 (CC13 view-model pass), L104
(moved to Batch 14 perf), L109/UI21 (visual), L161/UI23 (layout/live-verify).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:26:01 -04:00
debont80 d131ee67d5 feat(audit): Batch 12 — a11y & discoverability
- L118: decouple URL-field focus. The DownloadBar registers a focuser (its own
  input ref) in the nav store; App calls useNav.getState().focusUrlField() on
  launch / "New download" instead of document.getElementById('aerofetch-url').
  The hardcoded id is removed — App no longer reaches into DownloadBar's markup.
- L155: the accent swatches are now a real role="radiogroup" with aria-checked
  role="radio" swatches, roving tabindex, ←/→/↑/↓/Home/End arrow-key navigation,
  and the shared focus ring — matching the app's SegmentedControl radio pattern.
  Redundant "(selected)" aria-label text dropped (aria-checked conveys it).
- UX21: keyboard-shortcut discoverability — an Onboarding "Press Ctrl+K anytime"
  tip, and the command palette's Settings action surfaces its "Ctrl ," shortcut.
- UX22 / L67: onboarding is revisitable — a "Show welcome tips again" button in
  Settings → About re-opens the welcome screen.

Verified & documented (no code needed): UX17 (Fetch/Paste already have aria-label
+ focus-reachable Hint tooltips — a11y met; a permanent visible label is a compact-
layout choice), UI24 (context menus "none by design" — row actions are all visible
inline buttons + keyboard, and Fluent's Menu is portal-based, which the app avoids
for the dev-GPU flicker reason).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:15:10 -04:00
debont80 661a4e7572 feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:

- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
  (non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
  tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
  and toasts the main-process reason instead of silently no-oping on a moved/typed-
  out file. safeShowInFolder upgraded to return an error string like safeOpenPath
  (preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
  active (downloading + queued) count from any tab, via a count-only App selector
  that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
  explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
  no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
  earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".

Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:05:26 -04:00
debont80 814ecac287 feat(audit): Batch 10 — CC conventions (shared line-buffer, cleanError)
The contained consistency consolidations; the sweeping ones are deferred with
their standard documented (they're the audit's own post-1.0 refactors, and
several would be reckless to do unattended).

Landed:
- src/main/lib/lineBuffer.ts (createLineBuffer): one newline line-splitter for
  streamed child-process output, replacing the duplicated buffer loop in
  download.ts and terminal.ts (CC3/CC4/SIMP5). Pure + unit-tested
  (test/lineBuffer.test.ts, 7 cases: partial lines, CRLF, empty lines, flush,
  multi-line chunks). download.ts keeps byte-identical marker parsing and no
  close-flush (yt-dlp template lines are always newline-terminated).
- ytdlp.ts: getYtdlpVersion/updateYtdlp now run stderr through cleanError, the
  same `cleanError(r.stderr) || r.error?.message || ''` idiom already used by
  download/probe/indexer (CC6) — a failed check shows the trailing error line.
- CL6 resolved: the one real redundancy was L15; clearDir/openFile/showInFolder
  are deliberate view-model wrappers (CC13), left as-is.

Deferred with documented standard + rationale (CODE-AUDIT.md): CC1 (breaking
persisted-key rename → migration), CC5+CL4 (security-critical updater
net.request, not live-verifiable here), CC7 (cosmetic arg-order), CC9 (zod dep),
CC10 (electron-store↔jsonStore split is deliberate for DPAPI), CC11 (config.ts
move), CC12 (file-tree reorg), CC13→L93, CC14→L142.

Verified: typecheck (node+web) + 275 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:48:54 -04:00
debont80 3d4e574916 feat(audit): Batch 9 — surface primitives (empty-state, banner, truncation)
Shared UI primitives to end the "two ways to do X" drift on surfaces:

- EmptyState (ui/EmptyState.tsx): one centered empty block — optional focal
  icon/badge + Body1 message + optional muted hint, with a `compact` variant
  for in-content placeholders. Adopted by Downloads, History, Library (L116),
  and — compact — the Terminal log, Diagnostics, and TemplateManager (L117).
- Banner (ui/Banner.tsx) + LinkSuggestion (ui/LinkSuggestion.tsx): one tinted
  notification row (icon + truncating/wrap content + actions, tone brand/warning)
  replacing five hand-rolled style blocks — the DownloadBar copied-link
  suggestion / channel nudge / duplicate warning and the Library copied-link
  suggestion (UI19).
- useTextStyles().truncate: shared single-line-ellipsis utility, adopted in
  Library rows + the playlist panel + Banner; `title` now also sets minWidth:0
  so it shrinks in a flex row (L126).
- L102: Settings "Default format" now uses the same SegmentedControl (Video/
  Audio) + quality Select as the DownloadBar — one mental model; the combined
  `kind|quality` string encoding is gone.

Resolved by verified convention (documented in code / CODE-AUDIT.md):
- L103 busy = in-button icon→Spinner everywhere (done in L134; no adjacent-
  spinner pattern survives).
- L115 list-row actions icon-only+tooltip vs card-toolbar actions labelled.
- L120 Fluent <Card> for static content cards; tokenized surface recipe for
  interactive/list-item cards.
- UI20 toggles = solid brand, list-selection = brand-tint (rule noted on
  SegmentedControl); the cited Library pill is gone via UI18.

L128 (build-time DCE of preview seeds) deferred to Batch 14 with PERF8.

Verified: typecheck (node+web) + 268 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:37:32 -04:00
debont80 bd7ba8726e feat(audit): Batch 6-7 — design tokens + type/color roles, full migration
Landed complete design-token foundation (Batch 6) and type/color standardization (Batch 7):
- New src/renderer/src/components/ui/tokens.ts: SPACE, RADIUS, ELEVATION, Z, ICON, MOTION, SCRIM, META_SEP scales
- New src/renderer/src/components/ui/text.ts: useTextStyles hook + type-role convention (Subtitle2/Text/Body1/Caption1)
- Migrated scattered literals in 17 renderer files to named tokens
- Adopted unified muted color and row-title component across 7 list views
- All tile glyphs snapped to ICON tiers (no literal px remain)
- base.css reduced-motion gate + prefers-color-scheme alignment
- Verified tile-glyph visual deltas (≤2px) for watched pass; UI3/UI12 implementation confirmed, awaiting visual check
- CODE-AUDIT.md reconciled: Batch 6 complete (12 items checked, UI3+UI12 flagged); Batch 7 complete (6 items checked)

Verified: typecheck (node+web) + 268 tests + eslint + production build green; touched files prettier-clean.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 15:24:00 -04:00
debont80 e6b4f343e0 feat(config+ux): batch #5 — source maps, integration script, copy, redownload options
Clean config/copy items (Phase 7):
- L170: build.sourcemap 'hidden' on main/preload/renderer — emit .map for offline
  crash symbolication (of the CC8 logger's raw stacks) without exposing them.
- L34: add `test:integration` npm script (AEROFETCH_REAL_DOWNLOAD=1) for the opt-in
  real-download suite; stays out of `npm test` (needs network + live yt-dlp).
- L87: HistoryEntry carries the original `options`; redownload replays them so a
  re-download reproduces the same post-processing instead of the current defaults.
- L96: folder "Browse" buttons -> "Browse…" (they open the OS picker).
- L97: PO-token placeholder "(none)" -> "Optional -- paste a token" (consistent).
- Ticked already-resolved: L99 (M28 aria-label), L113 (UX2 tooltip=aria-label),
  L95 (usage follows verb/modifier rule), L172 (skipLibCheck accepted).

typecheck + 268 tests + eslint + prettier green (touched files LF/clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:06:06 -04:00
debont80 cc4fb132e4 feat(ux): UX16 — autofocus the URL field on launch
An App mount effect focuses #aerofetch-url when the Downloads tab is active
(double-rAF for paint), so a user can paste + type immediately instead of
clicking first.

typecheck + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:35:44 -04:00
debont80 ab825488b8 feat(win): W2/UX19 persist window placement, W10 taskbar flash; tick W11
- W2/UX19: windowState.ts persists the window's normal bounds + maximized flag to
  <userData>/window-state.json (no new dep); createWindow restores from it with a
  screen.getAllDisplays() visibility guard so it never reopens off a disconnected
  monitor. Debounced save on resize/move + save on close (which may hide to tray).
- W10: notify() flashFrame(true)s the taskbar when a completion/failure lands while
  the window is unfocused/minimized (Windows clears the flash on focus).
- W11: already implemented (badge.ts overlay dot + setOverlayIcon) — ticked.

typecheck + 268 tests + eslint + prettier green. (Window restore + flash want a
live quit/relaunch confirm.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:33:11 -04:00
debont80 64af608596 refactor(renderer): L132 — share resetForm() across download + addPlaylist
Extract the post-enqueue reset (URL + probe + one-shot trim/schedule/advanced/
incognito/options) into resetForm(), called by both download() and addPlaylist().
clearProbe() stays the lighter probe-only reset for onUrlChange. Also fixes a
latent inconsistency: addPlaylist previously left the trim/schedule/advanced
panels open after enqueuing a playlist.

typecheck + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:26:36 -04:00
debont80 809c99d5bb fix(renderer): L138/L145 clipboard privacy+dedup, M14 state-driven settings search
- L138: gate the clipboard check on the settings `loaded` flag (not just
  clipboardWatch), so it never reads using the pre-load fallback; the effect
  re-runs when loaded flips true so a pre-launch copied link is still offered.
- L145: lastSeen is module-level, so a dismissed clipboard link stays suppressed
  across the hook's remount on a Downloads<->Library tab switch.
- M14: SettingsView renders each card in a React-owned wrapper and drives its
  visibility from a `hidden` state array — the search no longer writes display:none
  onto a card's own DOM node. Matching still reads textContent (full label coverage,
  no per-card keyword upkeep).

typecheck + 268 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:17:23 -04:00
debont80 63f0a40f0e perf(renderer): memoize queue summary + stabilize list callbacks (L94/PERF4/PERF5/L163)
- L94/PERF4: add queueSummaryOf — a 1-entry items-ref memo over summarizeQueue.
  The store hands out a new items array per change, so App's taskbar subscription
  and DownloadsView's render compute the aggregate once per change (second caller
  hits the cache) instead of twice per tick. summarizeQueue stays pure/tested.
- PERF5: hoist VirtualList estimateSize/getKey/renderItem to stable module-level
  functions so the virtualizer doesn't churn its measure/key cache.
- L163: one useShallow selection replaces QueueItem's 10 individual useDownloads
  subscriptions (stable actions -> never re-renders the row).

typecheck + 268 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:09:09 -04:00
debont80 0a681f864f feat(m4): persist the download queue across restarts
The queue/scheduler lived only in renderer memory, so saved/scheduled and other
pending downloads were silently lost on quit (audit M4). Persist them:

- main: queue.ts store (proven cached-atomic createJsonStore, queue.json) +
  queue:list / queue:save IPC; QUEUE_MAX cap.
- shared: PersistedQueueItem = the durable subset of DownloadItem (runtime-only
  progress/speed/eta/sizeLabel/finishing dropped).
- renderer: mirror the persistable items on every queue change (a signature over
  the subset means progress ticks don't re-save; main's jsonStore coalesces the
  writes) and rehydrate on launch via an App useEffect. Pure mappers extracted
  to store/queuePersist.ts and unit-tested.

Restore semantics: downloading -> queued (yt-dlp continues the .part), saved +
scheduledFor survives so the promoter fires when due, paused/error return
actionable; completed/canceled are never persisted. A queueHydrated gate stops a
launch-time store change from wiping queue.json before it's read.

Reconciles ROADMAP.md. typecheck + 268 tests + eslint + prettier green.
(OS-level quit/relaunch cycle is worth a manual smoke test.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:25:58 -04:00
debont80 63687aaec3 docs(exec): correct execFileAsync comment — schedule.ts is not migrated
schedule.ts keeps its own schtasks wrapper because it needs the numeric exit
code (getScheduledSync checks r.code === 0), which execFileAsync deliberately
doesn't surface. The 5 migrated sites are probe/ytdlp(x2)/ffmpeg/indexer/
download.probeMeta. Comment-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:25:36 -04:00
debont80 abd9f688b9 docs(audit): update Phase 3 session note (execFileAsync/L136/R9 landed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:22:34 -04:00
debont80 81dd0ce742 fix(main): R9 — guard the yt-dlp update throttle against a backward clock
shouldAutoCheckYtdlp compared (now - lastCheck) >= interval; a backward system-
clock correction leaves lastCheck in the future so the difference is negative and
the daily check never comes due again. Treat a future lastCheck as skewed and run
the check. The renderer scheduled-item promoter is inherently fire-once (promotion
flips the item out of 'saved'), so it can't double-fire on a backward jump — a
forward jump firing early is documented and accepted as minor.

Unit-tested. typecheck + 263 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:19:00 -04:00
debont80 701c73a889 fix(main): L136 — enforce incognito in main (no errorlog / toast / cookie leak)
Incognito lived only in the renderer (history skip), so a "private" download
still leaked in main: errorlog entries (title+URL), OS toasts showing the title,
and the signed-in cookies attached — the UI promised "no logging, no history, no
cookies" but only history held.

Plumb `incognito` through StartDownloadOptions and enforce it in main:
- logFailure skips the errorlog write (and the pre-spawn errorlog in ipc.ts).
- notify uses a generic outcome title and drops the detail body, so no title/URL
  lands in the Windows Action Center.
- both cookie sources (--cookies login jar, --cookies-from-browser) are withheld,
  so a private download can't be tied to the user's identity.

typecheck + 262 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:14:49 -04:00
debont80 3baba3b7bc refactor(main): execFileAsync helper — unify the 5 spawn-and-read sites
Replace the hand-rolled `new Promise((resolve) => execFile(…, cb))` wrappers in
probe / ytdlp (×2) / ffmpeg / indexer.probeFlat / download.probeMeta with one
lib/exec.ts execFileAsync returning a normalized
{ok,stdout,stderr,timedOut,error} that never rejects. Each caller keeps its own
error mapping (timedOut -> "Timed out …" vs stderr) and JSON parsing; behaviour
is unchanged. windowsHide defaults on; per-call timeout/maxBuffer preserved.

Foundation for CC4/CC5 — the stdout line-buffer half lands with SIMP5, the
net.request half with SIMP16, at which point those checkboxes close.

Unit-tested (exec.test.ts) against the node binary: ok / stderr+nonzero-exit /
missing-binary / timeout. typecheck + 262 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:06:08 -04:00
debont80 66eaaac8fc docs(audit): tick CC8 (logging) + add "finish the audit" session note
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:56:07 -04:00
debont80 519e522917 feat(main): CC8 — file-backed leveled logger + renderer log sink
Diagnostics were invisible in a packaged build: main catches did a bare
console.error (DevTools suppressed in prod, M31) and the renderer's logError
(M29) wrote to an unreachable console. Add a small leveled logger
(src/main/logger.ts) appending to <userData>/logs/aerofetch.log with size-capped
rotation; all app/fs access is lazy+guarded so it's a safe no-op under tests.

- Route the 5 main-process console.* catch sites (jsonStore write-fail, settings
  write-fail + plaintext-secret warn, cookie loadURL, yt-dlp auto-update)
  through the logger.
- Add a fire-and-forget log:write IPC channel + preload logError() so the
  renderer's logError forwards failures to the main file sink (closes the M29
  "sink" half the code comments deferred to CC8). mockApi + Api type updated.
- Unit-test pure formatLine/composeMessage + no-op safety; update the R6
  jsonStore test to assert against the logger.

The user-facing toast half of CC8 ties to the global status surface (UI25/UX9).
typecheck + 258 tests + lint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:53:06 -04:00
debont80 6bd29efd23 build: Phase 2 — stop emitting unused .blockmap + clean dist per build
- L83: nsis.differentialPackage: false — the custom updater does full
  downloads and never consumes the differential blockmap, so don't generate it
  (NsisTarget gates blockmap generation on differentialPackage !== false).
- L84: add clean:dist script (fs.rmSync) run at the front of build:win so dist/
  no longer accumulates old-version installers (~3 GB observed). dist/out were
  already gitignored, so this is local-disk hygiene.

Config only; TS gate unaffected. 137 -> 135 open audit items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:39:38 -04:00
debont80 4dfb3c9867 docs(audit): Phase 1 — reconcile stale checkboxes + fix roadmap drift
Tick items whose root cause already landed, and fix two doc-drift items:

- L82: align ROADMAP-PINCHFLAT Phase F Source/MediaItem sketch to the shipped
  shared/ipc.ts shape (videoId/itemCount/watched/feedUrl + required
  url/playlistTitle/playlistIndex).
- L89: note H8's afterAllArtifactBuild checksum hook fixes release-artifact
  consistency going forward; stale dist artifacts handled by L84.
- Tick the wire-or-remove trio (M5/M6/UX1 — all wired in the DownloadBar
  Advanced panel) and MAX_ENQUEUE_BATCH (M33 fixed the false comment).
- Tick UX ref-duplicates resolved by their roots (UX10=H5, UX13=M22, UX14=L76,
  UX25=L78), the (ref) a11y line (UI33/UI30/UI28/UI29 all done), and the
  (OK)/(non-goal) Windows-conventions line.

Docs only; no code touched. 149 -> 137 open audit items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:36:51 -04:00
debont80 6a9856b590 Self-review: drop inert font-size/weight resets from the heading rule
The UI33 heading reset only needs to zero the UA margin — every heading in the
app carries a Fluent typography class that out-specifies this element selector
(and there are no raw headings), so the font-size/font-weight `inherit` lines
never applied and contradicted the comment. Keep just `margin: 0`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:19:41 -04:00
debont80 29ce270d46 a11y: Select disabled (UI31), native colorScheme (UI32), touch targets (W16), High Contrast (W18)
UI31 — the native <Select> wrapper now takes a `disabled` prop that passes to
the <select> and applies disabled foreground/background/stroke tokens +
not-allowed cursor, so it can show a disabled state like Fluent controls.

UI32 — dropped the datetime-local's explicit colorScheme:'light dark' so it
inherits the resolved in-app scheme from the app root; the native calendar popup
now follows the app's Light/Dark theme, consistent with the native <Select>,
instead of tracking the OS preference.

W16 — the icon-only row-action clusters (QueueItem + HistoryView) enforce a
>=40x40px hit target via a `& button` min-size rule (glyph unchanged, subtle
button just gains padding). Labels were already non-hover-only: the shared Hint
shows on :focus-within and every action has an aria-label. LibraryView item rows
have no icon-only actions, so nothing there needed changing.

W18 — defended the two color-meaning surfaces against forced-colors: accent
swatches set forced-color-adjust:none (they're color previews; otherwise all
flatten to one system color), and the SegmentedControl active segment paints
with system Highlight/HighlightText under @media (forced-colors: active) so the
checked state stays visible. Status chips (Fluent Badge) and thumbnail tints
correctly let the system palette win.

typecheck + 253 tests + eslint + prettier + production build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:11:30 -04:00
debont80 4aec32e3c2 a11y: semantic headings (UI33) + command-palette listbox semantics (UI27)
UI33 — render titles as real headings so Narrator heading-navigation works:
the shared ScreenHeader title is now <Subtitle2 as="h1"> (one h1 per screen in
one place — Downloads/History/Library/Settings/Terminal); DownloadsView "Queue
(N)" and all 11 settings cards are <Subtitle2 as="h2"> sections; Onboarding
"Welcome" is <Title2 as="h1">. A minimal h1–h6 margin reset in base.css zeroes
the UA heading margin (Fluent's typography classes out-specify the element
selector, so the visual ramp is unchanged) so layout matches the former spans.

UI27 — give the command palette proper combobox/listbox semantics: the input is
role="combobox" with aria-controls/aria-activedescendant/aria-autocomplete, the
list is role="listbox", and each row is role="option" with aria-selected, so the
active row is announced to screen readers via active-descendant. Focus stays on
the input (standard combobox pattern), so the rows became non-focusable divs;
onMouseEnter is kept to unify pointer + keyboard highlight on one sel state.

Also corrected UI30's checkbox in the audit (already implemented via the shared
SegmentedControl radiogroup; only the marker lagged).

typecheck + 253 tests + eslint + prettier + production build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:02:54 -04:00
debont80 36699531cf Format code with Prettier (8 modified files from H1 decomposition)
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-01 07:54:02 -04:00
debont80 9e0064aab2 Self-review H1: give useDownloadBar an explicit return type
The extracted hook relied on inferred return typing, inconsistent with the
codebase convention (useClipboardLink: ClipboardLink, useResolvedDark: boolean).
Add a compiler-verified DownloadBarController interface and annotate the hook —
the returned object is checked against it, so the surface can't silently drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:41:06 -04:00
debont80 e0afaf0fdf Mark H1 done in CODE-AUDIT (god files decomposed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:31:00 -04:00
debont80 ae12e7c9cf H1: decompose DownloadBar.tsx into a hook + panel + styles
Split the 858-line DownloadBar (~17 interdependent state hooks):
- downloadBar/useDownloadBar.ts: all state and behaviour (probe, playlist,
  trim/schedule, per-download options, dup guard, drag-drop, command preview),
  exposing wrapper handlers so preview-invalidation side-effects stay put.
- downloadBar/styles.ts: the makeStyles block.
- downloadBar/PlaylistPanel.tsx: the playlist-selection panel.

DownloadBar is now a render-only shell driven by the hook. `usingFormats` no
longer narrows `info` across the hook boundary, so the format Select guards with
`info?.formats ?? []` (only reached when usingFormats is true). Behaviour
unchanged; full typecheck + lint + build + 248 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:30:40 -04:00
debont80 e4e175736f H1: decompose SettingsView.tsx into one component per card
Split the 1104-line SettingsView (~11 cards, ~30 selector subs, ~20 useState)
into components/settings/*.tsx, each owning its own store subscriptions, local
state, and handlers:
- DownloadsCard, AppearanceCard, PostProcessingCard, NetworkCard, CookiesCard,
  CustomCommandsCard, FilenamesCard, BackupCard, DiagnosticsCard,
  SoftwareUpdateCard, AboutCard
- settingsStyles.ts holds the shared makeStyles hook.

SettingsView is now a ~90-line shell: the search box + rootRef filter and the
card list. Each card still renders exactly one <Card> DOM node, so the
display-toggle search filter is unchanged. Behaviour identical; 248 tests +
typecheck + lint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:25:58 -04:00
debont80 ac99fd86a5 H1: split store/downloads.ts into types + item builder + preview seed
Extract from the 614-line store:
- downloadTypes.ts: all public types (DownloadItem, AddOptions, AddEntry,
  ChosenFormat, DownloadStatus, DownloadState). Re-exported from downloads.ts
  so every `import { ... } from '../store/downloads'` site is unchanged.
- downloadItem.ts: buildItem/titleFromUrl + the RESOLVING placeholder.
- downloadSeed.ts: the preview-only mock seed array.

downloads.ts now holds only the store, recordCompletion, and the preview
ticker. Behaviour unchanged; 248 tests + typecheck green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:21:33 -04:00
debont80 4492eee054 Self-review: drop the formatter re-export shim, import @shared/format directly
The prior commit kept a pass-through `export { fmtBytes, fmtSpeed, fmtEta }` in
main/lib/formatters.ts purely so existing importers wouldn't have to change —
which re-exported fmtSpeed (zero importers, dead) and left download.ts re-
exporting fmtBytes/fmtEta with no consumer at all, undercutting the "one home"
goal.

Now the real consumers import from the canonical module directly:
- probe.ts and the download.test import from @shared/format.
- formatters.ts keeps only its internal `import { fmtBytes }` (for parseProgress)
  and no longer re-exports.
- download.ts re-exports only parseProgress (its own function); the dead
  fmtBytes/fmtEta re-export is removed.

No behavior change. typecheck + 248 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:10:11 -04:00
debont80 3d09174c16 H4 + H2 formatter half: carry raw numbers across the progress boundary
Closes H4 and the remaining formatter half of H2.

- New @shared/format.ts is the single home for fmtBytes/fmtSpeed/fmtEta, imported
  by both main and renderer. main/lib/formatters.ts re-exports them.
- DownloadProgress now carries raw speedBytesPerSec/etaSeconds instead of
  pre-formatted speed/eta strings. main's parseProgress emits the raw numbers.
- The renderer stores the raw numbers on DownloadItem; QueueItem formats them for
  per-item display, and summarizeQueue sums/maxes them directly. The lossy
  string->number->string round-trip in queueStats (parseSpeed / parseEtaSeconds /
  a local formatSpeed / formatEta) is deleted, along with a duplicate fmtEta in
  store/downloads.ts.
- Per-item and aggregate speed now use the same 1024-based scale; the aggregate
  previously used a 1000-based formatter (a latent inconsistency).

Tests updated for the raw-number contract (parseProgress, summarizeQueue).
typecheck + 248 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:03:14 -04:00
debont80 d050e48af6 H2 (URL half): one canonical youtubeId in urlHelpers
Consolidates the scattered YouTube-URL parsing (the clean, renderer-contained
half of H2):

- Move the complete youtubeId (watch/shorts/embed/live/youtu.be) into
  lib/urlHelpers.ts as the single home.
- thumb.ts, queueStats.ts (sameVideo), and store/downloads.ts (titleFromUrl)
  now import it; the three ad-hoc reimplementations are removed.
- titleFromUrl gains correctness: it previously labeled ANY host with a ?v=
  param as "YouTube video (…)" and missed youtu.be/shorts; it's now host-correct
  and covers every YouTube shape. sameVideo now also dedupes /shorts/ID against
  /watch?v=ID.
- Unit-tested in test/clipboardLink.test.ts (+4 cases, 247 pass).

The formatter half of H2 is deferred to travel with H4: the renderer re-parses
speed/eta strings only because raw numbers aren't carried across the IPC boundary
(H4), and fmtBytes (1024) vs formatSpeed (1000) differ intentionally.

typecheck + 247 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:54:09 -04:00
debont80 8267da50dc Mark M5/M6/UX1 done in CODE-AUDIT (wired in the prior two commits)
Housekeeping: the command-preview / incognito / per-download-options features
were implemented and self-reviewed in the previous commits but their audit
checkboxes were still open. Marks M5, M6, UX1 done with fix notes. Completes the
SHOULD-fix-for-1.0 release-gate tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:49:29 -04:00
debont80 e0465bc45e Self-review M5/M6/UX1: accurate preview, invalidate on change, reset overrides
Fixes found reviewing the previous commit:

- M5 preview was inaccurate: loadCommandPreview omitted formatId/formatHasAudio
  and trim, so the "exact command" differed from what download() actually spawns
  whenever a probe-selected format or a trim range was set. toggleCommandPreview
  now mirrors download()'s StartDownloadOptions (format + trim included).
- Stale preview: a shown command only cleared on URL change. Now it also clears
  on kind, quality, format, trim, download-options change, and on probe — so the
  displayed command is never out of sync with the form.
- Command button is now a real toggle (Show command / Hide command) instead of a
  no-op "Command · shown" label.
- Per-download overrides are now one-shot: incognito + downloadOptions reset to
  defaults after a download, matching trim/schedule and preventing incognito from
  silently carrying into the next download.
- Style: replaced two inline style={{}} usages with makeStyles classes
  (commandError), hardcoded 'monospace' -> tokens.fontFamilyMonospace, dropped an
  unnecessary fragment, and let long command lines wrap (pre-wrap/break-all).

typecheck + 243 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:46:33 -04:00
debont80 cfa76314d7 Wire M5/M6/UX1: command preview, incognito mode, per-download options (Recommended SHOULD-fix-for-1.0)
M5 (Command preview):
- Added 'Show command' button in DownloadBar that calls window.api.previewCommand()
- Displays the exact yt-dlp command line that will run for the current form state
- Helps users verify the exact behavior before download

M6 (Incognito mode):
- Added incognito checkbox in Advanced panel
- Passes through AddOptions to enable no-logging, no-history, no-cookies mode
- QueueItem already shows 'Private' badge when item.incognito is set

UX1 (Per-download options):
- Expanded Advanced panel now shows full DownloadOptionsForm
- Allows overriding audio format, video codec, container, embed-subtitles,
  SponsorBlock, format-sort for any single download (not just global settings)
- Options passed through AddOptions.options to startDownload
- Same form component used for both Settings defaults + per-download overrides

UI additions:
- Advanced toggle button (Settings icon) in DownloadBar
- Collapsible Advanced panel with incognito checkbox + DownloadOptionsForm
- Command preview panel shows the yt-dlp argv (monospace) or error message
- Both features only show for single videos (not playlists)

All plumbing was already in place (previewCommand IPC, StartDownloadOptions.options,
AddOptions.incognito, mockApi); only the renderer UI was missing.

typecheck + 243 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:41:03 -04:00
debont80 7e3e5af52d Fix C1/C2 + reliability cluster (L140/L141/L148, R6/R7)
Critical:
- C1: single source of truth for IPC mock + Settings defaults. DEFAULT_SETTINGS
  in @shared/ipc; main DEFAULTS, renderer FALLBACK, and preview MOCK_SETTINGS all
  derive from it. Whole browser mock collapsed into one typed mockApi.ts; main.tsx
  shrinks to window.api = mockApi. PREVIEW check single-sourced in isPreview.ts.
- C2: break the downloads<->sources circular import via a typed event bus
  (store/coordinator.ts). App eagerly imports sources for side-effects so startup
  load + scheduled --sync + downloadCompleted subscription still run.

Reliability:
- L140/L148: cancel/pause release the active slot synchronously; identity-guarded
  releaseActive; per-spawn cookie jar so a same-id retry/resume is never rejected
  by a stale entry nor run over the concurrency cap.
- L141: renderer history capped at 500 + url de-dup to match main.
- R6: writeJsonAtomic reports/logs write failures and keeps data dirty for retry
  instead of an empty catch.
- R7: encryptSecret warns before the plaintext fallback.

typecheck + 243 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:26:18 -04:00
debont80 6c19899f75 Self-review: fix stale comment quoting renamed empty-state string
Review of this session's audit batch (L106/L107/L122, L119/L151/L153, M3, M17,
M18, M23/M27, R8, L62). One issue found and fixed:

- SettingsView copyErrorReport comment quoted the old "No errors logged."
  empty-state string, which L119 renamed to "No errors yet." Reworded the
  comment to not quote a specific string.

Everything else verified sound: M3 recordCompletion is a faithful extraction
(+ a correct canceled-item bugfix); M17 status precedence; M18 migration; M23
per-source budget cap; M27 enqueueKind state is safe (only the expanded source
renders its detail); R8 pretty flag; L62 drop of the synthetic ext has no
consumers. No BOM churn. The incidental em-dash->-- conversions in
jsonStore/sources comments leave each file internally consistent and aligned
with the project's ASCII convention, so they stand.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:02:42 -04:00
debont80 0f572488fc Fix L62: drop hardcoded ext:'mp4' from the synthetic "Best available" format
The auto-best format option claimed ext:'mp4', but the real output container
for the best pick depends on the user's videoContainer setting (mp4/mkv/webm),
so it mislabeled mkv/webm outputs. ext is optional on FormatOption and the
option's label is the fixed "Best available", so the field is simply omitted.
hasAudio:true is kept (the best pick always merges an audio track).

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:55:19 -04:00
debont80 15d64b7ba6 Fix R8: compact JSON for the large media-items store
writeJsonAtomic always pretty-printed (JSON.stringify(value, null, 2)), which
needlessly inflates size and write time for the media-items store (up to
MAX_ITEMS=20000 rows). Added an optional `pretty` flag (default true, so
history/sources/templates/errorlog stay hand-inspectable) threaded through
createJsonStore, and set it false for media-items.json.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:53:42 -04:00
debont80 6ee4dbe78e Fix M23/M27: cap per-source on re-index; per-batch kind for library enqueue
M23: replaceMediaItems wrote [...newItems, ...others] and the JSON store sliced
     the combined list to MAX_ITEMS, so re-indexing one large source could
     silently evict ANOTHER source's tail. Now other sources are always kept in
     full and only the source being (re)indexed is capped to the remaining
     budget (MAX_ITEMS - others.length).

M27: enqueueItems forced settings.defaultKind for every item, so a channel
     couldn't be queued as audio without flipping the global default. Added an
     optional `kind` override (falls back to the default) and a video/audio
     SegmentedControl in the Library action row, seeded from defaultKind so
     existing behavior is unchanged until toggled.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:51:33 -04:00
debont80 8cbfc90f5d Fix M18: make audio quality labels format-agnostic ("Best", not "Best (MP3)")
The audio quality preset "Best (MP3)" named a format that's wrong whenever the
audio format is opus/flac/wav. Renamed it to "Best" so the label never
contradicts the chosen audioFormat (which is picked separately). Bitrate presets
stay as bitrates and are already ignored for lossless formats (M21).

- AUDIO_QUALITY_OPTIONS[0]: 'Best (MP3)' -> 'Best' (buildArgs already had a
  `case 'Best'` returning '0', so arg generation is unchanged).
- Updated the main + renderer defaults and the preview/mock/test fixtures.
- Added a one-line migration in getSettings() that rewrites a stored legacy
  'Best (MP3)' to 'Best' so existing users' dropdowns match.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:46:55 -04:00
debont80 af6c4d3c67 Fix M17: statusByUrl picks highest-priority status for duplicate URLs
LibraryView mapped url -> status by last-write, so when a URL had multiple
queue entries ("Download anyway"), the row reflected whichever item was
iterated last. Added a STATUS_PRIORITY ranking (completed > downloading >
queued > saved > paused > error > canceled) and keep the highest-priority
status per URL, so a row shows the most meaningful state of that video.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:37:52 -04:00
debont80 c55e131fb0 Fix M3: extract shared recordCompletion() for download-done side-effects
The completion side-effect (add to history unless private + markDownloaded on
the source MediaItem) was duplicated in applyEvent('done') and the preview
fake-ticker, and had already drifted (the preview copy omitted formatId/
formatHasAudio). Extracted one recordCompletion(item) helper that both call.

Also tightened a latent bug: the old applyEvent ran markDownloaded for any
'done' event with a mediaItemId, even when the item was canceled (the reducer
keeps canceled items as 'canceled'). recordCompletion now runs only when
status === 'completed', so a canceled collection item is no longer wrongly
marked downloaded (and will be retried on the next re-sync).

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:36:13 -04:00
debont80 eb94e84707 Fix L119/L151/L153: empty-state copy, range dashes, dismiss aria-labels
L119: Standardized empty-state copy on the "No X yet." pattern. Terminal
      "Output will appear here." -> "No output yet."; Diagnostics
      "No errors logged." -> "No errors yet."

L151: Range-dash convention is ASCII hyphen everywhere. Changed the lone
      en-dash prose range ("2-3 is a good balance") to a hyphen, matching the
      time-range placeholders (1:30-2:00).

L153: The three "Dismiss" close buttons now name what they dismiss --
      "Dismiss suggested link" (DownloadBar + Library suggestion banners) and
      "Dismiss duplicate warning" (DownloadBar dup row).

L95 left open: the Cookies-card "sign in"/"sign-in" usage is already correct
English (verb vs modifier); no change warranted.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:33:22 -04:00
debont80 0d2000f307 Fix L106/L107/L122: unify refresh icons, spinner size, busy-button feedback
L106: Established the convention — ArrowClockwise = retry/redo a local action
      (retry-all, re-download, retry queue item); ArrowSync = network sync/
      update (sync sources, reindex, check/apply updates). Flipped the two
      misclassified sites (LibraryView "check for new" sync, SettingsView
      "Update yt-dlp") to ArrowSync and dropped the now-unused imports.

L107: QueueItem queued-row Spinner was size="extra-tiny"; every other spinner
      is "tiny". Standardized to "tiny".

L122: The app-update and yt-dlp-update buttons showed "Checking…"/"Updating…"
      in the label AND a separate sibling Spinner. Folded the spinner into the
      button icon (icon swaps to Spinner while busy), matching DownloadBar/
      LibraryView, and removed the redundant sibling Spinner.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:29:19 -04:00
debont80 d7b5737806 Self-review fixes: revert ineffective/regression-risk changes from audit batch
Addressing issues found reviewing this session's audit commits:

REGRESSIONS FIXED
- updater.ts (L52): reverted the spawn(detached) installer launch. Raw
  CreateProcess/spawn fails with ERROR_ELEVATION_REQUIRED if the NSIS build
  flips to perMachine (the config comment invites this), and it silently
  dropped shell.openPath's launch-error detection. Restored shell.openPath
  (ShellExecute honors the elevation manifest) and kept the L52 goal by
  replacing the 1500ms timer with setImmediate(app.quit).
- LibraryView.tsx (M15): native <button> doesn't inherit color (UA sets
  ButtonText), so the source-card chevron (currentColor) would render wrong
  in dark mode. Added color: colorNeutralForeground1 to cardHead.

INEFFECTIVE CHANGE REVERTED
- base.css (W18): forced-color-adjust:auto on * is the CSS default (no-op),
  and [class*="backdrop"] never matches Fluent/Griffel's hashed atomic class
  names. Reverted; W18 unmarked (needs real High-Contrast testing).

WEAK FIX REVERTED
- Onboarding.tsx (L67): "Skip" and "Get started" called the identical handler
  on a single-screen onboarding. Removed the duplicate button; L67 unmarked
  (real ask is a "show tips again" revisit affordance).

STYLE / CORRECTNESS
- Stripped UTF-8 BOMs accidentally added to Select/CommandPalette/App/
  Onboarding by the PowerShell sanitizer; left pre-existing BOMs untouched.
- DownloadBar.tsx: merged the duplicate @shared/ipc import; removed a stray
  double blank line.
- qualityOptions.ts (L29): restored the original `satisfies Record<MediaKind,
  readonly string[]>` constraint + the noUncheckedIndexedAccess rationale
  comment (the extraction had dropped both for a weaker `as const`).
- downloads.ts: removed double blank line left by the QUALITY_OPTIONS move.
- binaries.ts: corrected YTDLP_MISSING_MSG doc comment ("at startup" -> the
  actual call sites).

typecheck + 242 tests + eslint all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:11:13 -04:00
debont80 ab085f2bfe Fix L26: consolidate drag-drop URL extraction into shared urlHelpers
firstUrl() in DownloadBar was a local duplicate of looksLikeUrl iteration
(scanning multi-line text for the first valid URL). Promoted to
firstUrlInText() in src/renderer/src/lib/urlHelpers.ts alongside the other
URL utilities, re-exported from useClipboardLink.ts. DownloadBar now imports
firstUrlInText from there instead of maintaining a local copy.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:50:39 -04:00
debont80 9abd2c4441 Fix L67/L86: Onboarding Skip button; clear hidden selections on filter change
L67: Onboarding now has a 'Skip' button alongside 'Get started', both in a
     right-aligned flex row. Both call update({hasCompletedOnboarding:true})
     so the behavior is the same — Skip just reads as optional for users who
     want to get started immediately without reading the tips.

L86: HistoryView now clears any selected IDs that are no longer visible when
     the filter (query or kind) changes. A useEffect on `filtered` intersects
     the current selection with the new visible set and calls setSelected only
     if the size actually changed (avoids re-renders when the filter doesn't
     affect the selection).

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:48:51 -04:00
debont80 6c7caa269c Fix L85: move launch.json from .claude/ to .vscode/ where VS Code reads it
.claude/launch.json was committed but VS Code reads launch configs from
.vscode/launch.json, so no tool was consuming it. Moved to the correct
location and removed the misplaced file from git tracking.

Also added .vscode/settings.json to .gitignore so local VS Code user
preferences don't end up tracked.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:47:03 -04:00
debont80 5241abbea9 Fix W18/W19: high-contrast support + window title already reflects state
W18: Added explicit forced-color-adjust:auto on the universal selector so
     the browser's HC mode can override all custom token colors. Added an
     @media(forced-colors:active) rule that replaces the CommandPalette
     backdrop's rgba(0,0,0,0.32) with the Canvas system color + a ButtonText
     border, so the palette remains perceivable under Windows High Contrast.

W19: Already fixed in the SR8 commit (window title now reflects download
     state via mainWindow.setTitle in the taskbarProgress handler). Marking
     done here with the companion CSS change.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:45:42 -04:00
debont80 fa4b41e0a5 Fix L48: strip ERROR: prefix from errorlog seed data to match production
cleanError() strips leading 'ERROR:' from yt-dlp stderr before sending
errors to the UI and error log. The browser-preview seed entry in
store/errorlog.ts had the raw 'ERROR: [youtube]...' prefix, making the
preview inconsistent with what production shows. Removed the prefix from
the seed string so the preview and production rendering match.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:44:18 -04:00
debont80 036ef74362 Fix SR8: update window title to reflect active download state
The window title was always 'AeroFetch' regardless of activity, so screen
readers and taskbar previews couldn't tell whether downloads were running.

The taskbarProgress IPC handler (which already fires on each progress tick)
now calls mainWindow.setTitle() alongside setProgressBar/setOverlayIcon:
- Idle: 'AeroFetch'
- Downloading: 'AeroFetch -- N downloads active'
- Error: 'AeroFetch -- Error'

This reuses the same overlay badge label string so the text is consistent
between the taskbar tooltip and the title bar.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:41:15 -04:00
debont80 04109d8220 Fix W8/W9: add Ctrl+, Settings shortcut; restore focus on palette close
W8: Added Ctrl+, as a standard Settings accelerator alongside the existing
    Ctrl+K palette shortcut. The key handler now handles both in one listener
    so there's a single window.addEventListener lifecycle.

W9: Command palette now restores focus to the previously focused element when
    it closes. prePaletteRef stores document.activeElement at open time;
    onClose fires a requestAnimationFrame focus-restore after unmounting the
    palette, so the focus returns to the trigger (a sidebar nav button, the
    URL input, etc.) rather than leaving focus stranded at the document body.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:39:51 -04:00
debont80 53aaed5fef Fix M15: replace role=button divs with native button elements in LibraryView
Both interactive headers in LibraryView used role="button" on divs, which is
invalid when they contain child interactive elements (group header had nested
All/None and Download buttons).

- Group header (groupHead): split into an outer flex div + a native <button>
  (groupToggle style) for the toggle/expand action + sibling Fluent Buttons
  for All/None and Download. The toggle button gets aria-expanded and a
  composite aria-label (title + item count). e.stopPropagation() removed
  from the sibling buttons since they are no longer inside the toggle.

- Source card header (cardHead): converted from a role="button" div to a
  native <button> with width:100%, border:none, and background:transparent.
  aria-label set to source.title. tabIndex/onKeyDown removed (native button
  handles keyboard by default).

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:37:33 -04:00
debont80 2f59a6ce63 Fix M10: consolidate .url Internet Shortcut parsing into shared utility
Both DownloadBar (renderer, drag-drop) and deeplink.ts (main, argv) had their
own regex to extract URL= from a Windows Internet Shortcut file -- with subtly
different patterns (/^\s*URL\s*=\s*(\S+)/ vs /^URL=(.+)$/).

Added parseUrlShortcutContent() to @shared/ipc. Both callers now delegate to
it and apply their own http/https guard (looksLikeUrl in renderer, asHttpUrl
in main). The local parseUrlFile wrapper in DownloadBar is kept as the
validation compositing point; it's now a one-liner.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:35:00 -04:00
debont80 a4fd0feb6b Fix L29/L52: QUALITY_OPTIONS moved to UI module; installer spawn via child_process
L29: QUALITY_OPTIONS was a presentational constant living in store/downloads.ts,
     coupling views to the store. Moved to src/renderer/src/qualityOptions.ts,
     which assembles it directly from @shared/ipc. store/downloads.ts no longer
     imports VIDEO/AUDIO_QUALITY_OPTIONS. Both consumers (DownloadBar, SettingsView)
     updated to import from the new module.

L52: Replaced the fixed `setTimeout(app.quit, 1500)` installer handoff with a
     detached spawn. The installer is spawned with stdio:'ignore' and unref'd so
     it outlives AeroFetch even on a slow machine. app.quit() fires on setImmediate
     so the IPC response returns to the renderer before the process exits.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:32:50 -04:00
debont80 1f2465576b Fix L19/L73: replace setTimeout focus hack with double-rAF; unify probe verb
L19: App.tsx replaced `setTimeout(() => focus(), 60)` in the "New download"
     palette action with `requestAnimationFrame(() => requestAnimationFrame(...))`
     to wait exactly one React render + paint cycle rather than an arbitrary 60ms
     timer that could race on slow machines.

L73: DownloadBar's probe button label changed from "Fetch" to "Check URL" (hint
     and aria-label both updated). Placeholder text updated to match. This
     distinguishes it from LibraryView's "Index" action, which catalogs an entire
     channel/playlist, making both labels clearly meaningful in context.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:29:07 -04:00
debont80 3ae4a5357c Fix L46/L71/L74: unify yt-dlp missing message, editable dir inputs, swatch a11y
L46: Introduced YTDLP_MISSING_MSG constant in binaries.ts. All four callers
     (download, indexer, probe, ytdlp) now emit the same user-facing string:
     "yt-dlp.exe is missing. Open Settings -> Software update to re-download it."
     This removes newline-embedded messages and four divergent phrasings.

L71: Removed readOnly from videoDir/audioDir folder inputs in SettingsView.
     Added onChange handler so users can paste or type a path directly without
     being forced to use the Browse button. The path still flows through
     applySettings() in main, which validates it.

L74: Accent color swatches were triple-labeled (aria-pressed + aria-label +
     title). Replaced with a single aria-label that embeds "(selected)" state,
     plus title for the hover tooltip. Removed the incorrect aria-pressed
     (these are selection buttons, not toggles).

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:27:02 -04:00
debont80 b035a88873 Fix L6/L12: Select size=large variant, command palette scroll-into-view
L6: Select.tsx adds a `size` prop ('medium' | 'large'). When size='large',
    the select is 40px tall (matching Fluent size='large' Input/Button) with
    slightly larger padding and font. DownloadBar quality/format selects now
    use size='large' so they align with the row's large Input and buttons.

L12: CommandPalette keyboard navigation now scrolls the highlighted item into
     view on each ArrowUp/ArrowDown press. Uses a listRef + data-sel attribute
     so no DOM imperative tracking of individual buttons is needed.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:22:50 -04:00
debont80 6a8452d6c3 Fix L9/L28/M11: shared thumb sizes, live defaults, clipboard IPC
L9: Extract four thumbnail box dimensions into src/renderer/src/thumbSizes.ts
    (THUMB_LG 120x68, THUMB_MD 108x64, THUMB_SM 72x44, THUMB_XS 60x34).
    DownloadBar, QueueItem, HistoryView, and LibraryView all import and use
    them instead of hard-coded strings -- one place to change the 16:9 sizing.

L28: DownloadBar now reflects Settings changes to defaultKind/defaultQuality
     in real time, but only while the URL field is empty (idle state). A URL
     typed or probed in progress is never discarded by a settings change.

M11: Clipboard reads now consistently use window.api.readClipboard (IPC) in
     the paste button, matching the clipboard-watcher hook. navigator.clipboard
     is no longer used for reads anywhere in the renderer.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:20:21 -04:00
debont80 3df32d0505 Fix L22/L24/L27: kind-aware form, shell openUrl, Enter-to-download
L22: DownloadOptionsForm accepts optional `kind` prop. When set, hides
     audio-specific fields (Audio format) for video downloads and vice versa
     (Video container, Preferred codec) for audio downloads. Settings passes
     defaultKind so the defaults card shows only relevant options.

L24: Replace lone window.open('htmlUrl', '_blank') with a new openUrl IPC
     channel (shell:open-url) that calls shell.openExternal after validating
     the protocol is http/https. Consistent with how all other external opens
     work in the app. Preload + main handler + mock updated.

L27: DownloadBar URL-field Enter is now smart -- if formats/playlist are
     already loaded (or probe errored), Enter triggers download; otherwise
     it probes. Eliminates the keyboard dead-end where Enter never downloads.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:16:15 -04:00
debont80 c038b94c6f Fix L14/L20/SR10/L150: CSS baseline, client datalist, copy polish
L14: base.css gets * { box-sizing: border-box } (removes ad-hoc swatch rule),
     :focus-visible outline using the Fluent brand token color, and
     -webkit-font-smoothing / -moz-osx-font-smoothing antialiasing.

L20: youtubePlayerClient Input now has a datalist of known client names
     (web, web_safari, web_embedded, mweb, tv, ios, android) so users
     get autocomplete suggestions. Still accepts free text for future clients.

SR10: Sidebar tagline changed from 'yt-dlp frontend' to 'Video downloader'
      (plain-English, not developer shorthand).

L150: Standardized 'PO Token' capitalization -- SettingsView label now says
      'YouTube PO Token (advanced)' consistently.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:11:36 -04:00
debont80 67c0cd8bf4 Fix L8/SR9/M20: list keys, jargon hints, ProgressBar a11y
L8: TerminalView lines now carry a stable auto-increment id (keyed by that
    instead of array index) so front-trimming at MAX_LOG_LINES doesn't cause
    React to reuse wrong DOM nodes. SettingsView error list keyed by id alone
    (not id+occurredAt, which was redundant since IDs are already unique).

SR9: DownloadOptionsForm -- rewrote 'Container for merged video' to a plain
     description; rewrote format-sorting hint to remove raw '-S string' /
     'yt-dlp syntax' jargon.

M20: All ProgressBars now carry aria-label -- QueueItem (downloading +
     paused rows, keyed on title/URL), DownloadsView overall summary bar,
     SettingsView app-update download bar.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:09:36 -04:00
debont80 4e3b141d6d M19: Move sidebarCollapsed from localStorage to electron-store
Sidebar collapsed state was an orphan in localStorage -- not backed up,
lost on portable installs, and inconsistent with every other preference.

Added sidebarCollapsed: boolean to the Settings type and DEFAULT_SETTINGS,
wired it through applySettings' boolean branch, and updated the renderer
store / main.tsx mock defaults. App.tsx now drives the toggle via
updateSettings({ sidebarCollapsed }) instead of localStorage.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:04:30 -04:00
debont80 63a327a31c Fix L7/M13/M37: badge colors, secret masking, dev-jargon hints
L7: Canceled status chip now uses 'subtle' color (neutral) instead of 'warning'
    (amber) -- distinguishing it from paused which remains amber.
M13: Proxy URL and YouTube PO-token inputs now use type="password" to mask
     credentials, matching updateToken. Proxy URLs can carry user:pass; PO tokens
     are opaque secrets.
M37: Rewrite two dev-facing hints -- youtubePlayerClient now says 'how AeroFetch
     identifies itself to YouTube' instead of citing yt-dlp internals; filename
     template says 'Controls how saved files are named' instead of 'yt-dlp output
     template'. Remaining hints (cookie browser, aria2c, etc.) were already clean.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:01:25 -04:00
debont80 94c5723246 Fix L23/L78/L79: diagnostics hint, empty-state copy, SponsorBlock defaults
L23: Diagnostics error list now shows "Showing 20 of N errors." when there
     are more than 20 logged errors.
L78: DownloadsView empty-state copy updated from "Paste a URL above to get
     started" to "Paste a URL above, then click Download" -- matches the UI.
L79: SponsorBlock default categories expanded from ['sponsor'] to
     ['sponsor', 'selfpromo', 'interaction'] -- sponsor-only was silently
     covering almost nothing; the three new defaults are universally unwanted.
     Existing SB tests updated to pass explicit categories instead of using defaults.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:57:53 -04:00
debont80 3086ac469f Fix L16/L75/L77: datetime formatting, update-token visibility, ffmpeg re-check
L16: relTime now caps at weeks/months instead of unbounded 'N d ago' (adds w/mo
     buckets). formatWhen omits the year when the timestamp is in the current year.
L75: Update-token Field in the Software-update card is hidden by default; only
     shown when a token is already configured or an update-check error occurred.
L77: Adds a Re-check button next to the ffmpeg/ffprobe version display so a
     previously-not-found binary can be re-detected without restarting the app.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:54:19 -04:00
debont80 910cf23e61 Fix L17/L18/L56: regex validation, terminal visibility, SponsorBlock warning
L17: TemplateManager urlPattern field now validates the regex on each keystroke;
     an error message appears below the input and Save is blocked until fixed.
L18: Terminal nav item is hidden when customCommandEnabled is false -- no more
     dead-end gated view. Sidebar gains showTerminal prop; App.tsx reads the
     setting and passes it through.
L56: SponsorBlock Categories Field shows a 'warning' validationMessage when the
     toggle is ON but no categories are selected, making the silent no-op visible.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:48:41 -04:00
debont80 ba208de98b Fix L54/L61/L63: thumbnail error fallback, ipcMain.handle, audioQuality label
L54: DownloadBar preview image now tracks failed src in state (same pattern as
     MediaThumb); on load error shows the kind icon fallback instead of broken img.
L61: Convert taskbarProgress from ipcMain.on/ipcRenderer.send to ipcMain.handle/
     ipcRenderer.invoke so all IPC channels use the same request-response idiom.
     App.tsx calls void to explicitly discard the resolved promise.
L63: audioQuality() now has an explicit 'Best' case alongside the bitrate strings;
     unknown labels still fall back to '0' but the default branch is documented.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:45:45 -04:00
debont80 9d816f0c86 Fix L49/L53/L59: error string newlines, dialog null-safety, shared theme constant
L49: Remove newline from missing-binary error message in download.ts; inline
     error spans render it as literal newline, now one sentence with period.
L53: backup.ts exportBackup/importBackup/showMessageBox no longer use win!
     non-null assertions -- each uses a guarded ternary (same pattern index.ts
     already used for chooseFolder).
L59: Extract PAGE_BACKGROUND to src/shared/theme.ts; main/index.ts and
     renderer/src/theme.ts both import from it -- no more "keep in sync" comment.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:42:22 -04:00
debont80 72462cab1e Fix H3/L30/L44/L60/L90/L25/L32 polish: dependencies, naming, consistency
L30: Remove redundant loading=lazy from MediaThumb images inside the
     virtualizer which only mounts visible rows anyway.
L44: electron-builder.yml now excludes the actual eslint.config.js and
     .prettierrc rather than non-existent legacy .eslintrc/.prettierrc.yaml.
L60: IpcChannels.externalUrl renamed from external-url to app:external-url
     to match every other channel name category prefix.
L90: settings.ts sanitizeOptions now uses (ARR as readonly string[]).includes
     pattern consistently with setSettings; removes the as-never hack.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:37:05 -04:00
debont80 69d68e1854 Fix H3/M33/L25/L32: fix probe dependency, correct ipc comment, throttle taskbar, memoize palette
H3: probe.ts now imports fmtBytes from lib/formatters instead of download.ts,
    breaking the circular probe->download dependency direction.
M33: ipc.ts comment corrected to reflect that no batch cap exists; notes the
     known M33 issue for future implementation.
L25: App.tsx taskbar subscriber skips IPC when fraction/mode/badge unchanged,
     eliminating redundant roundtrips on every progress tick.
L32: paletteActions wrapped in useMemo so CommandPalette sees a stable array
     reference; only rebuilds when isDark changes.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:32:29 -04:00
debont80 8ab85da67e Fix H5/H6/M21/L37-40/L47/L55/L68/L72/L76/L91/L98/L108/L165/L166 audit items
H5: HistoryEntry now stores formatId+formatHasAudio; redownload passes them
    back to addFromUrl so the original yt-dlp format is reused, not guessed.
    Compound quality labels stripped to the preset prefix for old history.
L72: thumbnail now passed in redownload so re-queued rows keep their art.
H6: TerminalView log capped at MAX_LOG_LINES=2000 to prevent unbounded growth.
M21: --audio-quality omitted for lossless audioFormats (flac/wav).
L166: fmtEta hour rollover fixed in all 3 locations -- 2h00:00 not 120:00.
L165: queueStats formatSpeed precision aligned with fmtBytes.
L55: QueueItem suppresses sizeLabel when already in probed-format quality label.
L47: probeMeta null sends empty meta event so Resolving clears via SR6.
L68: notifiedBackground resets on window show for subsequent close-while-downloading.
L76: dup warning falls back to URL when title is still a placeholder.
L91: SettingsView onFormatSelect uses indexOf instead of unsafe tuple cast.
L98: Embed chapters field gets a hint text.
L108: BrowserWindow created with explicit title AeroFetch.
L37: pure formatters extracted to src/main/lib/formatters.ts and unit-tested.
L38: buildArgs test covers webm thumbnail suppression.
L39: CROP_SQUARE_PPA test is structural not exact-string.
L40: looksLikeUrl/looksLikeSingleVideo extracted to src/renderer/src/lib/urlHelpers.ts.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:29:35 -04:00
debont80 1376c2dee8 Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified —
typecheck + 234 tests + eslint + prettier green).

Correctness / bugs:
- B3: match the release checksum to the asset's filename line (no wrong-hash verify)
- B4: newline-safe metadata probe (one --print with a unit-separator delimiter)
- B5 / L88: guard the meta event against canceled items; progress no longer promotes
  a queued item outside pump()
- B7: cookie-login promise always resolves (handles destroy-without-close)
- L146: trim parser rejects >2 colon-group times; M36: Library selection counts only
  actionable rows
- L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message,
  schedule picker min, dead-code/comment cleanup

Type safety:
- Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed)

Resilience / Windows / metadata:
- R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth)
- W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window;
  L147 dead macOS branches removed
- CL1: shared stdout markers; package/builder metadata (license, homepage, repository,
  copyright, tsbuildinfo glob)

Copy / docs / tests:
- M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled
- New unit tests for L35 (isValidMediaItem) and L36 (compareVersions)

This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard
work it builds on: background running + auto-download, library clipboard detection,
tray, binary management & library scale, credential encryption at rest, the shared
jsonStore and ui/ primitives, and the eslint/prettier tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:02:54 -04:00
debont80 a6a8c5f578 Update CODE-AUDIT.md: living checklist with stable IDs
Reformat from a one-off security report into a living checklist.
Adds the 2026-06-29 architectural review findings and a second
polish pass; all original security findings marked completed.
Items carry stable IDs for tracking across sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:47:45 -04:00
wayne d112bc4878 Background running for downloads/auto-download, library clipboard detect, updater auth (#7)
Co-authored-by: Wayne <bontragerl464@gmail.com>
Co-committed-by: Wayne <bontragerl464@gmail.com>
2026-06-29 10:17:50 -04:00
debont80 5a9f2de390 Encrypt credentials at rest; unify clipboard-link suggestions
Encrypt proxy / youtubePoToken / updateToken on disk via safeStorage (DPAPI on Windows), with a one-time launch migration for legacy plaintext. Decrypted before reaching callers/renderer; backup export still writes clear, as documented. Harden updater token handling to refuse cross-origin redirects on the authenticated REST check.

Extract the clipboard-link logic from DownloadBar into the shared useClipboardLink hook: add an optional filter (library skips single-video links), an offer() for external aerofetch:// / .url links, and a source field driving banner wording. Add looksLikeSingleVideo plus its unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:16:16 -04:00
wayne f167c02946 Background running for downloads/auto-download, library clipboard detect, updater auth + token
Closing the window no longer kills in-progress downloads, the library gains the
same copied-link suggestion the downloads tab has, and the in-app updater can now
authenticate to a sign-in-required Gitea.

Background / tray:
- The window's close handler now hides to the tray (instead of quitting) whenever
  a download is in flight, even if "Keep running in the tray" is off — quitting
  was killing the spawned yt-dlp processes. A one-time notification explains the
  app is still running. (download.ts exposes hasActiveDownloads().)
- tray.ts now falls back to an embedded icon when no build/icon.ico ships, so the
  tray actually appears — previously an empty icon meant the tray was skipped and
  a minimized window could be stranded with no way back.
- New "Start with Windows" setting (launchAtStartup) wired to
  app.setLoginItemSettings, synced at startup and on toggle. Useful with
  auto-download so watched channels stay current in the background.
- The "Keep running in the tray" hint now explains it also enables background
  auto-download of new uploads.

Library clipboard detection:
- Extracted the downloads tab's clipboard watcher into a shared useClipboardLink
  hook and used it in the library's add-source field, so a copied channel/playlist
  link is offered there too.

Updater fix (works on a private / sign-in-required instance):
- Added an optional updateToken setting. When set, the updater sends it as a Gitea
  Authorization header on the release check, the checksum fetch, and the installer
  download — so "Check for updates" works where anonymous access is blocked (the
  previous "could not reach the update server" case). Blank = anonymous, unchanged.
  No token is ever shipped; it's only ever sent to the host-pinned update host.
  Settings gains a masked "Update access token" field.

Typecheck clean; 187 tests pass; electron-vite build clean. New UI verified in the
browser preview (tray/startup toggles, token field, library copied-link banner).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:17:16 -04:00
212 changed files with 21853 additions and 5955 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
{
"name": "ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "ui"],
"port": 5174
"runtimeArgs": ["run", "ui", "--", "--port", "4321", "--strictPort"],
"port": 4321
}
]
}
+6
View File
@@ -10,3 +10,9 @@ dist
# Secrets — never commit
.gitea-token
# Retired: the baked update token was removed from the build and its service
# account revoked; kept ignored so a stray local .update-token can never be committed.
.update-token
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
.vscode/settings.json
+7
View File
@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none",
"endOfLine": "auto"
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "ui"],
"port": 5174
}
]
}
-342
View File
@@ -1,342 +0,0 @@
# AeroFetch Code Audit
**Date:** 2026-06-23
**Scope:** Full security & correctness review of main process, preload, IPC contract, and renderer stores
**Overall:** Strong security fundamentals (context isolation, sandboxing, argument-injection defense); findings are refinements, not foundational gaps
---
## Executive Summary
AeroFetch's security posture is notably thoughtful:
- **Layered argument-injection defense**: `assertHttpUrl` validators + `--` terminator before the URL, preventing yt-dlp flag injection
- **Tight process model**: `contextIsolation: true`, `sandbox: true`, `nodeIntegration: false`; thin preload forwarding only typed IPC
- **File opening safety**: [reveal.ts](src/main/reveal.ts) allowlist restricts to media extensions, blocks `.exe`/`.bat`/`.ps1`
- **Settings validation**: Type-checked before persistence; `spawn` used without `shell: true`
- **Good test coverage** of risky logic (especially [buildArgs.test.ts](test/buildArgs.test.ts))
The findings below are opportunities to tighten existing defenses and fix performance gaps. None are foundational breaks — with one exception added later: **C1**, a missing bundled `ffprobe.exe` that broke duration-dependent post-processing, surfaced by the real-download smoke test and now fixed.
---
## Findings
### Security
#### S1 — Backup import enables arbitrary custom-command templates silently
**File:** [backup.ts:54-55](src/main/backup.ts)
**Severity:** Medium
**Status:** Fixed — 2026-06-23
**Description:**
`importBackup` restores settings + templates from a user-chosen JSON file. The code validates only that `CommandTemplate` fields have the right shape (id, name, args), then immediately applies them via `setSettings`. If the backup contains `customCommandEnabled: true` + `defaultTemplateId` pointing to a template with `args: "--exec 'cmd'"`, the next download will spawn arbitrary code with no user confirmation that the file carried custom commands.
**Why it matters:**
An attacker-supplied backup file smuggles code execution into an otherwise innocuous "import my settings" gesture. Unlike direct UI creation of templates, there's no moment where the user sees what's being enabled.
**Fix:**
Surface the count of imported custom commands and require explicit confirmation before applying. Consider importing templates in a disabled state. Alternative: show a preview of template names/args (first 100 chars) before committing.
---
#### S2 — Cookie login window allows popups of arbitrary schemes
**File:** [cookies.ts:119-125](src/main/cookies.ts)
**Severity:** Low
**Status:** Fixed — 2026-06-23
**Description:**
The login window's `setWindowOpenHandler` returns `action: 'allow'` for every popup without protocol filtering. A logged-in webpage could spawn windows with `file://`, custom protocols, or other non-HTTP schemes in the shared session partition. The main window [index.ts:118-126](src/main/index.ts) correctly restricts to `http:`/`https:` only.
**Why it matters:**
Defense-in-depth: the sandbox limits damage, but an attacker page could open a `file://` popup to exfil cookies to disk, or use a custom-protocol popup as a pivot.
**Fix:**
Apply the same protocol check to login popups:
```typescript
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
} catch { return { action: 'deny' } }
return { action: 'allow', ... }
})
```
---
#### S3 — Concurrency cap is renderer-only (defense-in-depth gap)
**File:** [downloads.ts:249-265](src/renderer/src/store/downloads.ts)
**Severity:** Low
**Status:** Fixed — 2026-06-23
**Description:**
`maxConcurrent` is enforced solely by the renderer's `pump()` function. `startDownload` in main ([download.ts:214](src/main/download.ts)) only checks for duplicate IDs, not active count. A buggy or compromised renderer could spawn unbounded yt-dlp processes.
**Why it matters:**
Defense-in-depth: the current design assumes the renderer is trusted. If it ever leaks (XSS in an injected thumbnail URL, future bundled library), the main process should still cap spawns.
**Fix:**
Add a main-process active-download count check in `startDownload`:
```typescript
if (active.size >= settings.maxConcurrent) {
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot.' }
}
```
---
#### S4 — `filenameTemplate` and `outputDir` have no path-traversal checks
**File:** [settings.ts:150-157](src/main/settings.ts)
**Severity:** Low
**Status:** Fixed — 2026-06-23
**Description:**
These fields are validated as `typeof === 'string'` only. A template like `%(title)s\..\..\..\win32.exe.%(ext)s` is joined into `-o` ([download.ts:195](src/main/download.ts)) and permits yt-dlp to write outside the intended folder. Also, `proxy` may carry plaintext credentials (user:pass@...) that get serialized by `exportBackup`.
**Why it matters:**
On a single-user machine, self-inflicted. But on a shared PC (the app's stated target use case), directory traversal lets one user overwrite another's files.
**Fix:**
Sanitize `outputDir` (must be absolute, within a reasonable parent) and `filenameTemplate` (reject `..` and absolute paths). For proxy, either mask credentials in backups or document that they're stored plaintext.
---
#### S5 — Persisted JSON (history, templates, errorlog) read without per-field validation
**Files:**
- [history.ts:17-18](src/main/history.ts)
- [templates.ts:16-17](src/main/templates.ts)
- [errorlog.ts:16-17](src/main/errorlog.ts)
**Severity:** Low
**Status:** Fixed — 2026-06-23
**Description:**
All three do `JSON.parse(...) as T[]` with only an `Array.isArray` gate and no per-field validation. A hand-edited or corrupted file yields entries the UI blindly trusts (e.g., a `HistoryEntry` with `filePath: "C:\\bad"` that passes through to `openPath`). Inconsistent with the rigorous validation elsewhere.
**Why it matters:**
Low immediate risk (malformed entries mostly degrade gracefully). But it's an untrusted-input boundary that doesn't match the codebase's defensive posture.
**Fix:**
Add schema validators (`ts-json-validator`, `zod`, or lightweight custom checks) for each type:
```typescript
function isValidHistoryEntry(obj: unknown): obj is HistoryEntry {
return obj && typeof obj === 'object' && 'id' in obj && typeof obj.id === 'string' && ...
}
```
---
### Correctness
#### C1 — Bundled `ffprobe.exe` was missing — duration-dependent post-processing failed for every user
**Files:** [resources/bin/README.md](resources/bin/README.md), [binaries.ts](src/main/binaries.ts), [download.ts:235](src/main/download.ts)
**Severity:** High
**Status:** Fixed — 2026-06-23
**Description:**
`resources/bin/` shipped `ffmpeg.exe` but **not** `ffprobe.exe`, and the README only documented copying `ffmpeg.exe`. yt-dlp resolves *both* binaries from `--ffmpeg-location <dir>`; without `ffprobe.exe` it cannot read media durations, so any post-processor that needs one fails at runtime with `ERROR: Postprocessing: Unable to determine video duration: ffprobe not found`. That breaks `--sponsorblock-remove`, `--force-keyframes-at-cuts`, and `--split-chapters` for **every** user — end-user machines have no system ffprobe either. It slipped past review because thumbnail/crop/metadata post-processing only uses ffmpeg, and typecheck can't see a missing binary.
**Found by:**
The real-download smoke test ([real-download.integration.test.ts](test/real-download.integration.test.ts)) — the SponsorBlock-remove case failed with exit 1 (`ffprobe not found`) until ffprobe was bundled. Everything else (crop, audio re-encode, container/codec, subs, chapters, restrict-filenames, archive, extra-args) passed.
**Fix:**
Copied the matching `ffprobe.exe` (same `n8.1.2` LGPL build, verified by SHA-256 against the already-bundled `ffmpeg.exe`) into `resources/bin/`, and updated the README to list it as a required binary alongside `ffmpeg.exe`. The integration suite now also asserts `ffprobe.exe` is present in `beforeAll`.
**Hardening (done — 2026-06-23):**
`startDownload` now asserts `ffmpeg.exe` and `ffprobe.exe` presence up front ([download.ts](src/main/download.ts)), alongside the existing `yt-dlp.exe` check — a future missing binary returns a clear AeroFetch error naming the file, instead of a cryptic mid-download yt-dlp postprocessing failure. (`getFfprobePath()` added to [binaries.ts](src/main/binaries.ts).)
---
### Performance
#### P1 — `getSettings()` writes to disk on every read
**File:** [settings.ts:91-98](src/main/settings.ts)
**Severity:** Medium
**Status:** Fixed — 2026-06-23
**Description:**
Every call to `getSettings()` unconditionally runs `s.set('downloadOptions', sanitizeOptions(...))` and (on first run) `s.set('outputDir', ...)`. `getSettings()` is on hot paths: `buildCommand`, notification checks, system-theme bridge updates, several IPC handlers. Since `electron-store` serializes to disk on each `set`, this means synchronous file I/O per settings read.
**Why it matters:**
Unnecessary disk churn. Settings reads are frequent (before every download, on IPC calls). Visible on slower machines or filesystems.
**Fix:**
Sanitize once at store init or wrap `set` in a dirty-flag check:
```typescript
export function getSettings(): Settings {
const s = getStore()
const cur = s.store
const sanitized = sanitizeOptions(cur.downloadOptions)
if (sanitized !== cur.downloadOptions) s.set('downloadOptions', sanitized)
// Only write outputDir once, on first launch
if (!cur.outputDir) s.set('outputDir', app.getPath('downloads'))
return s.store
}
```
---
#### P2 — Every download spawns a redundant metadata probe
**File:** [download.ts:249](src/main/download.ts)
**Severity:** Medium
**Status:** Fixed — 2026-06-23
**Description:**
`startDownload` always calls `probeMeta` (a second yt-dlp process with `--skip-download`) in parallel with the real download to fetch title/channel/duration. However, the renderer typically already probed and passes this metadata via `addFromUrl`'s optional `meta` parameter ([downloads.ts:60-65](src/renderer/src/store/downloads.ts)). That metadata is never forwarded to `startDownload`, so the main process re-fetches it.
Result: doubled network traffic and process spawns; the async metadata completion can overwrite good titles the renderer already provided.
**Why it matters:**
Wasted I/O on every download, especially for large playlists (where the renderer pre-probed each entry). On slow connections, observable slowdown.
**Fix:**
Add optional `meta` fields to `StartDownloadOptions` and use them if present:
```typescript
export interface StartDownloadOptions {
// ... existing fields ...
meta?: DownloadMeta // optional pre-probed metadata
}
// In download.ts:
let resolvedTitle = opts.meta?.title ?? (await probeMeta(...))
if (opts.meta) {
send(wc, { type: 'meta', id: opts.id, meta: opts.meta })
} else {
probeMeta(ytdlp, opts.url).then(...)
}
```
---
#### P3 — Lowering `maxConcurrent` mid-flight doesn't pause overflow
**File:** [downloads.ts:249-265](src/renderer/src/store/downloads.ts)
**Severity:** Low (UX)
**Status:** Fixed — 2026-06-23 (documented in code; intentional behaviour)
**Description:**
`pump()` only gates *future* promotions of queued items. Reducing `maxConcurrent` while N downloads are active leaves all N running (no immediate pause). Acceptable behavior, but not obvious.
**Why it matters:**
Users may expect "set max to 1" to pause other downloads immediately. Instead, ongoing ones keep running until they finish.
**Fix:**
Document the behavior, or add a `cancel-overflow` handler if stricter semantics are desired. For now, a comment suffices.
---
### Maintainability
#### M1 — `cleanError` function duplicated
**Files:**
- [download.ts:81-88](src/main/download.ts)
- [probe.ts:139-146](src/main/probe.ts)
**Severity:** Trivial
**Status:** Fixed — 2026-06-23 (extracted to [log.ts](src/main/log.ts))
**Description:**
Identical 8-line error-log parsing function in two places. If the format changes, both need updating.
**Fix:**
Extract to a shared utility, e.g., `src/main/log.ts` or add to `download.ts` and import in `probe.ts`.
---
#### M2 — `parseExtraArgs` has no escape-sequence handling
**File:** [buildArgs.ts:112-120](src/main/buildArgs.ts)
**Severity:** Trivial
**Status:** Fixed — 2026-06-23 (limitation documented in code)
**Description:**
The shell-like split supports single and double quotes but no escape sequences (`\"` or `\'`). A value containing a literal quote can't be expressed, and an unterminated quote falls through to `\S+` and captures the quote itself. The tests cover the happy path; edge cases aren't reachable in practice (the UI doesn't expose raw quote entry).
**Why it matters:**
Limitation of the current design, documented nowhere.
**Fix:**
Add a note near `parseExtraArgs`: "No escape-sequence support; quotes cannot nest. For most yt-dlp use cases (proxy URLs, filename patterns), this is sufficient." If a future template needs quotes within quotes, redesign to a proper shlex or JSON-based format.
---
#### M3 — Release polish: app icon
**File:** [electron-builder.yml:48](electron-builder.yml)
**Severity:** Cosmetic
**Status:** Placeholder done — 2026-06-23; a *designed* asset is still wanted before v1.0
**Description:**
The `icon:` line was commented out pending a final icon asset, so builds fell back to the default Electron icon. Bundled binaries are correctly handled (graceful "not found" errors everywhere).
**Fix (done — placeholder):**
Added [build/icon.svg](build/icon.svg) — a white download glyph on the teal brand square, mirroring the in-app brand mark ([Onboarding.tsx](src/renderer/src/components/Onboarding.tsx)) — and rasterised it to a multi-size [build/icon.ico](build/icon.ico) (256/128/64/48/32/16) via ImageMagick. The `win.icon` line is now uncommented. Regenerate with:
```
magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico
```
**Still open:** swap the placeholder for a professionally designed icon before cutting v1.0.
---
## Preserved Strengths (Continue)
The following approaches are correct and should be maintained:
- **`assertHttpUrl` at every entry** (url.ts, probe.ts, download.ts, cookies.ts, deeplink.ts): consistent, belt-and-suspenders defense against argument injection
- **`--` terminator** before the URL in all spawned commands: ensures URL is never read as a flag
- **`reveal.ts` extension allowlist** (media + subtitle sidecars only): prevents arbitrary-file execution via compromised renderer
- **Process-tree kill on cancel** (`taskkill /T`): correctly cleans up spawned ffmpeg child
- **Separate cookie partition** for login: prevents the login page from accessing app session data
- **`spawn`/`execFile` without `shell: true`**: avoids shell-injection vulns like `%COMSPEC%` CVEs
- **Type-validated settings writes** before persistence: prevents malformed values reaching the store
- **Comprehensive unit tests** for `buildArgs` logic, including the gnarly ffmpeg crop-quoting case
---
## Test Coverage Gaps
The following security-critical functions lack tests:
- `sanitizeOptions` (settings.ts): all validation rules
- `importBackup` (backup.ts): malformed JSON, missing fields, custom-command ingestion
- `safeOpenPath`/`safeShowInFolder` (reveal.ts): extension allowlist enforcement
- `assertHttpUrl` (url.ts): protocol validation (had minor cases, now fixed)
**Action:** Add integration tests or snapshot tests for these before shipping.
---
## Remediation Roadmap
| ID | Category | Effort | Priority | Notes | Status |
|-----|----------|--------|----------|-------|--------|
| S1 | Security | 2h | High | UX confirmation on custom-command import | **Fixed 2026-06-23** |
| S2 | Security | 15m | Medium | Copy protocol check from main window | **Fixed 2026-06-23** |
| S3 | Security | 30m | Medium | Add active-count gate in main | **Fixed 2026-06-23** |
| S4 | Security | 1h | Medium | Path-traversal sanitization | **Fixed 2026-06-23** |
| S5 | Security | 1.5h | Low | Add per-field validators to JSON readers | **Fixed 2026-06-23** |
| P1 | Perf | 1h | Medium | Remove disk writes from `getSettings` hot paths | **Fixed 2026-06-23** |
| P2 | Perf | 1h | High | Forward renderer's pre-probed metadata to main | **Fixed 2026-06-23** |
| P3 | UX | 15m | Low | Document or enforce concurrency semantics | **Fixed 2026-06-23** (documented) |
| M1 | Maint | 15m | Trivial | Extract `cleanError` to shared module | **Fixed 2026-06-23** |
| M2 | Maint | 15m | Trivial | Document `parseExtraArgs` limitations | **Fixed 2026-06-23** |
| M3 | Polish | 30m | Trivial | Add icon asset before release | **Placeholder done 2026-06-23**; designed asset still wanted pre-v1.0 |
| C1 | Correctness | 30m | High | Bundle missing `ffprobe.exe` + document it (found by smoke test) | **Fixed 2026-06-23** |
---
## Notes
- **Deferred:** No findings warrant an emergency patch. S1 is the highest-impact (social engineering), but requires UI changes. S3/S4 are defense-in-depth; production use is low-risk on single-user machines.
- **Testing:** All changes should be accompanied by test additions (especially security validators).
- **DashMail parallel:** This mirrors DashMail's approach to staged remediation (high-impact first, polish last).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Devon Bontrager
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
<div align="center">
# AeroFetch
**A clean, native-feeling video &amp; audio downloader for Windows — a friendly front-end for [yt-dlp](https://github.com/yt-dlp/yt-dlp).**
Paste a link, pick a quality, and go. No command line, no Python setup, no chasing down FFmpeg — everything is bundled.
![AeroFetch downloads screen](docs/screenshots/downloads-dark.png)
</div>
---
## What it is
AeroFetch wraps the power of yt-dlp in a modern Windows desktop app. It handles single videos, whole playlists, and watched channels; it can grab audio-only, embed thumbnails and metadata, trim clips, skip sponsor segments, and keep a library of channels in sync — all from a point-and-click interface built with Fluent UI.
It ships as a **regular installer** *and* a **single portable `.exe`** (no admin rights required), with `yt-dlp`, `FFmpeg`, and `aria2c` bundled in — so it runs on a fresh machine with nothing else installed.
## Features
- **One-paste downloads** — drop in a video or playlist URL and press Enter. Clipboard detection can offer the link automatically.
- **Format &amp; quality control** — pick a resolution/codec (H.264 / VP9 / AV1), or extract audio to MP3, M4A, Opus, FLAC, WAV, or AAC.
- **Playlists &amp; channels** — select which videos to grab, or add a channel to your **Library** and let AeroFetch watch it for new uploads.
- **Post-processing** — embed thumbnails, metadata, chapters, and subtitles; **SponsorBlock** to skip or mark sponsor segments; trim to a start/end timestamp.
- **Queue that behaves** — concurrent downloads with a configurable cap, pause/resume, cancel, retry-all, and live speed/ETA.
- **Library &amp; sync** — organized per-source folders, scheduled daily sync, retention rules, and quality-upgrade selectors.
- **History** — searchable, filterable record of everything you've downloaded, with quick open-file / show-in-folder / re-download.
- **Fast &amp; convenient** — `aria2c` acceleration, a `Ctrl+K` command palette, light / dark / auto themes, and native Windows notifications.
- **Portable-friendly** — runs from a USB stick or Downloads folder without installing or needing admin rights.
## Screenshots
| Downloads (light) | Downloads (dark) |
| :---: | :---: |
| ![Downloads, light theme](docs/screenshots/downloads-light.png) | ![Downloads, dark theme](docs/screenshots/downloads-dark.png) |
| Library — watched channels &amp; playlists | History — searchable &amp; filterable |
| :---: | :---: |
| ![Library](docs/screenshots/library-dark.png) | ![History](docs/screenshots/history-dark.png) |
<div align="center">
**Settings — grouped &amp; searchable**
![Settings](docs/screenshots/settings-light.png)
</div>
## Download &amp; install
Grab the latest build from the [**Releases**](https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases) page:
- **`AeroFetch Setup <version>.exe`** — standard installer. Installs per-user (no admin), adds a Start-menu shortcut, and auto-updates.
- **`AeroFetch-<version>-portable.exe`** — single self-contained executable. No install, no admin; keeps its data in an `AeroFetch-data` folder beside the `.exe`. Ideal for locked-down or shared PCs.
Each release also publishes a `.sha256` next to every executable so you can verify the download.
> Because the executables aren't code-signed, Windows SmartScreen may warn on first launch — choose **More info → Run anyway**. No administrator rights are required either way.
## Build from source
Requires **Node.js 24.x**.
```bash
git clone https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git
cd AeroFetch
npm install
npm run dev # launch the app in development
npm run ui # preview just the renderer in a browser (http://localhost:5173)
npm test # run the test suite
npm run build:win # produce the installer + portable .exe in dist/
```
The bundled `yt-dlp.exe`, `ffmpeg.exe`, `ffprobe.exe`, and `aria2c.exe` live in `resources/bin/` and are packaged automatically.
## Tech stack
Built with **Electron** + **electron-vite** + **electron-builder**, a **React 19** / **TypeScript** renderer, **Fluent UI**, **Zustand** for state, and **Zod** for boundary validation. yt-dlp runs only in the main process; the renderer talks to it over a typed IPC bridge with context isolation enabled.
## A note on responsible use
AeroFetch is a tool for downloading media you have the right to download — your own uploads, content offered for download, or material whose license permits it. Respect the terms of service of the sites you use and the rights of content creators.
## License
AeroFetch's own source code is released under the [**MIT License**](LICENSE) — free to use, modify, and redistribute.
It also bundles third-party binaries that remain under their own licenses: **yt-dlp** (Unlicense), **FFmpeg** (LGPL/GPL), and **aria2** (GPL). AeroFetch invokes these as separate executables rather than linking against them, so they are distributed as an aggregate; their licenses apply to the respective files in `resources/bin/`.
-235
View File
@@ -1,235 +0,0 @@
# AeroFetch Roadmap — media-manager mode (Pinchflat parity)
The [Seal-parity roadmap](ROADMAP.md) took AeroFetch from "paste a URL → download" to a
full-featured yt-dlp frontend (Phases AE, complete). This document is a **second, parallel
track**: turning AeroFetch from a one-shot *downloader* into a *media manager* in the spirit
of [Pinchflat](https://github.com/kieraneglin/pinchflat) — download **entire channels**,
keep them **organized into `Channel / Playlist / Title` folders**, and (eventually) **keep
them in sync** as the channel posts new videos.
Nothing here removes or replaces existing behaviour. The single-video / hand-picked-playlist
flow (probe → format pick → queue → history) stays exactly as-is and remains the default. The
media-manager features are **additive**: a new "Library" section feeds the *same* download
queue, concurrency cap, history, error log, cookies, and post-processing options already built.
Sources: [Pinchflat GitHub](https://github.com/kieraneglin/pinchflat) ·
[Pinchflat architecture (DeepWiki)](https://deepwiki.com/kieraneglin/pinchflat) ·
[Pinchflat FAQ — indexing](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions).
---
## Implementation status — Phases FK shipped (2026-06-23)
All six phases are implemented and **rebuild-gated** (`npm run typecheck` + `npm run test`
+ `npm run build` clean after each). The unit suite grew from 76 → **106 tests** (new pure
logic in `indexerCore.ts` + the folder/sidecar args). UI phases were additionally verified
in the Vite browser preview.
| Phase | What shipped | Verification |
| --- | --- | --- |
| **F** | `classifySource`/`buildMediaItems`/`stableSourceId` (`indexerCore.ts`); channel-walk + persist (`indexer.ts`, `sources.ts`); IPC + preload | typecheck · 88 tests · build |
| **G** | `sanitizeDirSegment` + `collectionOutputTemplate` (`buildArgs.ts`); `CollectionContext` threaded through `buildCommand` | typecheck · 97 tests · build |
| **H** | Library sidebar tab + `LibraryView.tsx` + `store/sources.ts`; grouped tree, "Download N pending" → existing queue, live status pills | **preview-verified** |
| **I** | `mergeItemsPreservingState` (re-index keeps downloaded state, drops vanished, counts new); persist-on-complete via `markDownloaded` | **preview-verified** (state survives a queue clear) |
| **J** | Watched sources + RSS fast-check (`sync.ts`), per-source Watch toggle, "Check for new", `autoDownloadNew`; Task Scheduler (`schedule.ts`) + `--sync` launch | UI preview-verified; **OS-level scheduling/RSS fenced — needs a real-install smoke test** (same caveat as the `aerofetch://` wiring in ROADMAP.md) |
| **K** | `.info.json` / thumbnail / `.description` sidecars via `DownloadOptions` + `DownloadOptionsForm` "Sidecar files" group | typecheck · 106 tests · build + preview-verified |
**Deferred sub-items (noted, not yet built):** the power-user raw output-path template
setting (G bullet 3 — current layout is the fixed `Channel/Playlist/NNN - Title`); an
automatic incremental feeder so the live queue self-refills (H — today "Download pending"
enqueues a capped batch of `MAX_ENQUEUE_BATCH`); the retention / quality-upgrade re-download
(I stretch); a true headless quit-when-done for the scheduled `--sync` run (J — today it
launches the normal window unobtrusively and syncs on startup); and Kodi/Jellyfin `.nfo`
generation (K — the three native yt-dlp sidecars ship; `.nfo` needs a custom post-step).
---
## The core idea borrowed from Pinchflat
Pinchflat's insight is to **separate indexing from downloading**, with a persisted index in
between:
1. **Index** a *Source* (a channel or playlist) — enumerate *all* its videos with
`yt-dlp --flat-playlist` and persist one lightweight record per video, **regardless of any
filters**. Changing what you want to download never forces a re-index.
2. **Filter** that persisted list to decide what to actually fetch.
3. **Download** each chosen video as its own per-item yt-dlp run, placed into folders by an
output-path template, with `--download-archive` + a per-item `downloaded` flag preventing
re-downloads.
This is why "download an entire channel" doesn't have to flood the queue with thousands of
live cards (the failure mode of naïve per-video enumeration) and doesn't need a custom
playlist-progress parser (the failure mode of native `yt-dlp` playlist expansion in one
process): **the channel's full video list lives in a store; the live queue only holds what's
actively downloading.** AeroFetch's per-video progress, concurrency, cancel, retry, and
history all keep working unchanged.
### Data model — Pinchflat → AeroFetch
| Pinchflat (Elixir + SQLite + Oban) | AeroFetch (Electron + JSON store + existing queue) |
| --- | --- |
| **Source** (watched channel/playlist) | `Source` record in a new `sources.json` (mirrors `history.ts`) |
| **Media Item** (one discovered video) | `MediaItem` record (id, title, playlist, index, source id, `downloaded`) |
| **Media Profile** (download preset) | Maps onto existing `DownloadOptions` + `CommandTemplate` + a new output-path template |
| Oban `media_collection_indexing` queue | An async **index job** in main (the probe, persisted) |
| Oban `media_fetching` queue | The existing renderer download queue + concurrency cap |
| Oban `fast_indexing` (RSS) | Phase J — YouTube RSS re-sync |
| `--download-archive` + item state | The existing `--download-archive` setting + `MediaItem.downloaded` |
**Storage decision:** stay with plain-JSON stores (the pattern Phase D deliberately kept over
better-sqlite3 to avoid a native-module build step). A single channel is up to a few thousand
`MediaItem`s — still fine to hold and filter client-side. Revisit better-sqlite3 only if a
user indexes many large channels and the JSON files get unwieldy (noted as a Phase H risk).
---
## Phase F — Channel & playlist indexing (foundation) ✅ COMPLETE
Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
yet. This is the prerequisite for every later phase.
- [ ] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
playlist's video list on demand rather than all up front.
- [ ] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
```ts
interface Source {
id: string
url: string
kind: 'channel' | 'playlist'
title: string
channel?: string
addedAt: number
lastIndexedAt?: number
}
interface MediaItem {
id: string // yt-dlp video id — the dedup key
sourceId: string
title: string
playlistTitle?: string // for the folder path; 'Uploads' fallback
playlistIndex?: number // 1-based, for NNN numbering
durationLabel?: string
downloaded: boolean
downloadedAt?: number
filePath?: string
}
```
- [ ] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
no real playlist.
- [ ] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
## Phase G — Folder organization & output-path templates ✅ COMPLETE
Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`.
- [ ] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
joined subpath) so each `MediaItem` downloads into
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
- [ ] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
not the directory segments AeroFetch constructs from channel/playlist names.
- [ ] **Output-path setting.** A new Settings → Downloads field for the folder layout, defaulting
to `Channel / Playlist / Title`, with the raw yt-dlp `-o` template exposed for power users
(parallels how Phase C exposed raw extra args). Keeps the existing flat
`filenameTemplate` as the default for non-collection downloads.
- [ ] **"Media Profile" = named download preset (optional sugar).** Pinchflat bundles
quality + subs + output template into a reusable Profile. AeroFetch already persists
`DownloadOptions` and named `CommandTemplate`s; a Profile is just a named bundle of
`{ DownloadOptions, outputTemplate, extraArgs }` a Source can point at. Worth it once
multiple sources want different rules; skip for v1 (use the global defaults).
## Phase H — Library view (the media-manager UI) ✅ COMPLETE
The new surface where channels live. Everything below feeds the **existing** download queue.
- [ ] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
Downloads / History / Settings. Lists added Sources.
- [ ] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
Per-playlist select-all, live counts.
- [ ] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never
holds the entire channel at once — the live queue stays small while the Library view is the
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
records to history exactly as today.
- [ ] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
`store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data
so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`).
- [ ] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
## Phase I — Incremental sync & dedup ✅ COMPLETE
Re-running a channel should grab only what's new — the everyday media-manager loop.
- [ ] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
persisted `MediaItem`s and marks only the new ones; existing records (and their
`downloaded` state) are preserved. "X new since last sync" badge.
- [ ] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
can't re-download.
- [ ] **Retention / quality-upgrade (stretch, from Pinchflat).** Optional re-download to upgrade
quality/metadata, and optional pruning of old items. Defer — niche for a desktop app.
## Phase J — Subscriptions & scheduled auto-download ✅ COMPLETE (OS wiring needs smoke test)
Pinchflat's headline feature: a Source you *watch*, that downloads new uploads on its own.
AeroFetch already has the Windows pieces for this elsewhere in this workspace (Task Scheduler +
single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns).
- [ ] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
- [ ] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
(`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new
uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15
videos, so RSS is for *staying current*, not initial indexing (which stays a full
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
playlists / initial scans").
- [ ] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
enqueues new items, wired to **Windows Task Scheduler** (reuse the
single-instance-lock already added in Phase E so a scheduled run hands off to a running
window instead of double-launching). Notify on new downloads via the existing
`Notification` path.
## Phase K — Media-server output (optional stretch) ✅ COMPLETE
Make AeroFetch's output drop-in for Jellyfin/Plex/Kodi the way Pinchflat does — relevant
because organized channel folders are exactly what those servers ingest.
- [ ] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through
Phase C custom-command args; a checkbox group in the output settings makes it first-class.
---
## What we reuse vs. build
**Reuse unchanged:** the download queue + concurrency cap + cancel/retry (`store/downloads.ts`),
history + error log, cookies + proxy + rate-limit + aria2c (`AccessOptions`), all
post-processing `DownloadOptions`, `--download-archive`, native notifications, the
single-instance lock, and the JSON-store pattern.
**Build new:** channel-depth probing (Phase F), the `sources.json` index + `Source`/`MediaItem`
model (F), per-item folder output paths + sanitizer (G), the Library view + `useSources` store
(H), incremental diff-sync (I), and RSS + scheduled sync (J).
## Suggested order
F → G are the foundation and unlock a usable "download whole channel into folders" the moment
they land (drive it from a temporary button before the full Library UI exists). H makes it a
real product surface. I and J turn it into a true media manager. K is polish. Phases FH are
the high-value core; IK are incremental and each shippable on its own.
-236
View File
@@ -1,236 +0,0 @@
# AeroFetch Roadmap — feature parity with Seal
AeroFetch is the Windows/Electron counterpart to [Seal](https://github.com/JunkFood02/Seal),
the Android Material-You frontend for [yt-dlp](https://github.com/yt-dlp/yt-dlp). The core
loop (probe → format pick → queue → download → history) is already in place. This document
tracks the remaining **gap**: everything Seal does that AeroFetch doesn't yet.
Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
[Seal on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ·
[It's FOSS overview](https://itsfoss.com/news/seal/).
> **See also:** [ROADMAP-PINCHFLAT.md](ROADMAP-PINCHFLAT.md) — a parallel track that extends
> AeroFetch from a one-shot downloader into a [Pinchflat](https://github.com/kieraneglin/pinchflat)-style
> media manager (index entire channels → organize into `Channel / Playlist / Title` folders →
> keep in sync), reusing the existing queue/history/options rather than replacing them.
---
## Already at parity ✅
Video + audio download · audio → mp3 with embedded thumbnail/metadata · interactive format
picker (probe) · quality presets · queue + concurrency cap + cancel · download history
(open / show-in-folder) · clipboard auto-detect · light/dark theme · output folder +
filename template · yt-dlp version check · NSIS installer + portable build.
---
## Phase A — Core download power ✅ COMPLETE
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
(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
cut segments) are now all verified against live yt-dlp + ffmpeg in
[real-download.integration.test.ts](test/real-download.integration.test.ts). That pass
found and fixed a real shipping bug — the bundled `ffprobe.exe` was missing (see CODE-AUDIT C1).
- [x] **Playlist downloads.** Probe now uses `yt-dlp -J --flat-playlist` and returns either
a single video (with formats) or a flat playlist. The download bar shows a selection
panel (scrollable checkbox list, select-all/none, live count) and enqueues each chosen
entry as its own single-video download — reusing the existing queue/concurrency/history.
- [x] **Subtitles.** Download + embed (`--write-subs`, `--write-auto-subs`, `--sub-langs`,
`--embed-subs`, `--convert-subs srt`); language field + auto-caption toggle.
- [x] **SponsorBlock.** `--sponsorblock-mark` / `--sponsorblock-remove` with a category
multi-select (sponsor, intro, outro, selfpromo, music_offtopic…) + `--force-keyframes-at-cuts`.
- [x] **More audio formats.** Was mp3-only. Now best / mp3 / m4a / opus / flac / wav / aac
via `--audio-format`.
- [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 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.*
- [x] **Per-download overrides.** Collapsible Options panel in the download bar (shared
`DownloadOptionsForm`) so one download — or one playlist batch — can deviate from the
persisted defaults; "Reset to defaults" clears it. Threads `options` through
`addFromUrl → DownloadItem → startDownload` and is carried on retry.
## Phase B — Access & networking ✅ COMPLETE
`buildArgs`'s former `NetworkOptions` param is now `AccessOptions` (still in
`src/main/buildArgs.ts`) — it grew past pure networking once cookies and
filename/archive flags joined proxy/rate-limit/aria2c. **Smoke-tested ✅ (2026-06-23):**
`--restrict-filenames` (spaces → underscores) and `--download-archive` (a second run of
the same URL is skipped with no fresh download) are verified end-to-end. *Still untested
live: the sign-in window's cookie export — it needs an interactive login against a gated
video, which can't run unattended, so it stays reasoning + typecheck only. aria2c
(optional, binary not bundled) and proxy (needs a live proxy server) are likewise not
smoke-tested.*
- [x] **Cookies.** Settings → Cookies has a source picker:
- *From a browser's cookie store* → `--cookies-from-browser <name>` (chrome/edge/
firefox/brave/opera/vivaldi — `COOKIE_BROWSERS` in `src/shared/ipc.ts`).
- *Sign-in window (built into AeroFetch)* → opens an Electron `BrowserWindow` on a
persisted session partition (`persist:aerofetch-login`, see `src/main/cookies.ts`);
OAuth/SSO popups are allowed through onto the same partition. Closing the window
exports its cookies to a Netscape-format file (`userData/cookies.txt`) which
`buildArgs` passes via `--cookies`. Settings → Cookies shows last-saved time + a
"Clear saved cookies" button (also clears the partition's storage).
- [x] **aria2c external downloader.** Settings → Network has a "Use aria2c downloader"
toggle; when on and `resources/bin/aria2c.exe` exists, `buildArgs` emits
`--downloader <path> --downloader-args` (`ARIA2C_ARGS` in `src/main/buildArgs.ts`:
`-x 16 -s 16 -k 1M`). Missing binary falls back silently to yt-dlp's own downloader
(checked in `src/main/download.ts`). Binary is optional and not committed — see
`resources/bin/README.md`.
- [x] **Proxy** (`--proxy`) and **rate limit** (`--limit-rate`). Settings → Network fields
(`Settings.proxy` / `Settings.rateLimit`), threaded through `buildArgs`'s
`AccessOptions` param.
- [x] **Restrict filenames** toggle (`--restrict-filenames`) + **download archive**
(`--download-archive`, skip already-downloaded) — both in Settings → Filenames.
The archive file lives at a fixed `userData/download-archive.txt`
(`getDownloadArchivePath()` in `src/main/settings.ts`), not user-configurable.
## Phase C — Custom commands & yt-dlp management ✅ COMPLETE
Named `CommandTemplate`s (`src/shared/ipc.ts`) persist to `templates.json`
(`src/main/templates.ts`, mirrors `history.ts`'s plain-JSON pattern) and are managed
inline in **Settings → Custom commands** via `TemplateManager.tsx` — no modal, matching
the rest of the app's no-portal-overlay convention (see the Hint/Select comments re:
this dev machine's GPU/driver). A template's extra yt-dlp flags are shell-split
(`parseExtraArgs`, `src/main/buildArgs.ts`) and appended to the generated argv
immediately before the `--` URL terminator, so a template flag can override anything
chosen earlier. `Settings.customCommandEnabled` + `defaultTemplateId` apply a template
to every new download; the download bar's own **Custom command** panel lets a single
download override that default or opt out entirely (`extraArgs` on
`StartDownloadOptions`, mirroring the existing per-download `options` override).
`buildCommand()` (`src/main/download.ts`) now centralises settings/access/
options/extraArgs resolution, shared by both the real `startDownload` spawn and the new
preview path. All items verified in the UI preview + `npm run typecheck` +
`npm run test` (11 new unit tests covering `parseExtraArgs`, `formatCommandLine`, and
extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write-info-json
--no-mtime')` splits into discrete tokens and the flags reach a live yt-dlp run — the
`.info.json` sidecar is written, confirming custom-command args take effect end-to-end.
- [x] **Custom command templates.** Named, editable templates of extra yt-dlp args
(`CommandTemplate`), CRUD'd inline via `TemplateManager.tsx`; persisted to
`templates.json`. **Pick a default** via `Settings.defaultTemplateId` (a Select in
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.
- [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.
## Phase D — Library, UX & notifications ✅ COMPLETE
History stays plain JSON (`history.json`, 500-entry cap) — search/filter/select are done
client-side over the already-small array, so the planned better-sqlite3 migration wasn't
needed for this phase and was deliberately skipped to avoid a native-module build step for
no functional gain at this scale. Failed downloads (pre-spawn rejections and in-flight
yt-dlp failures alike) now persist to a new `errorlog.json` (`src/main/errorlog.ts`,
200-entry cap) independent of the queue, so a report survives `Clear finished`. Native
notifications and the backup file both reuse existing settings/templates plumbing
(`getSettings`/`setSettings`, `listTemplates`/the new `replaceTemplates`) rather than adding
parallel storage. All items verified in the UI preview (typecheck + `npm run test` clean).
- [x] **History search + multi-select + filter** (video/audio) + bulk delete + **re-download**.
`HistoryView.tsx` gained a search box, an All/Video/Audio filter, a "Select" mode with
per-row checkboxes + select-all + **Delete selected** (new `historyRemoveMany` IPC,
`src/main/history.ts`), and a per-row re-download button that re-queues the entry's
URL/kind/quality via the existing `addFromUrl`.
- [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.
- [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
merge-by-id. Surfaced as **Settings → Backup & restore**.
- [x] **Error log / copy error report** view (Seal's debug report). New
`src/main/errorlog.ts` persists every failure (pre-spawn rejection, spawn error, or
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
**Copy full report** and **Clear log**.
## Phase E — Theming & platform polish
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
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
`src/renderer/src/store/systemTheme.ts`) so the native window background stays in sync
too, not just the in-page theme. High contrast: `nativeTheme.shouldUseHighContrastColors`
surfaces as a status row in Settings → Appearance with a shortcut to Windows' contrast
settings (`ms-settings:easeofaccess-highcontrast`); actual color overrides are left to
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
toggle's break-out-of-system behavior all checked visually.
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
since shipping ~40 real translations isn't something to fake; doing this properly means
wiring up an i18n library + extracting every string now, with translation content
arriving incrementally from contributors.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target:
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
install time via `electron-builder.yml`'s `protocols` field (NSIS), and at runtime via
`app.setAsDefaultProtocolClient` in `src/main/index.ts` (covers the portable build and
dev, which have no installer step to do it for them).
- **Explorer "Send to AeroFetch"** — `src/main/deeplink.ts`'s `registerSendToShortcut()`
writes a `.lnk` into `%APPDATA%\Microsoft\Windows\SendTo` via Electron's
`shell.writeShortcutLink` (no native deps). Sending a `.url` Internet Shortcut file
(what Explorer/browsers create from "Create shortcut"/dragging a tab to the desktop)
passes its path as argv; `extractIncomingUrl()` parses the `URL=` line.
Both paths funnel through the same `extractIncomingUrl()` (only ever forwards http(s)
targets — same restriction as the existing external-link window-open handler) and a new
`external-url` IPC push channel. The renderer surfaces the incoming link via
`DownloadBar.tsx`'s existing clipboard-suggestion banner (now labeled "Link received:"
for this source instead of "Use copied link?") rather than a new UI element. Added
`app.requestSingleInstanceLock()` + a `second-instance` handler so a second invocation
(the OS relaunching us for the protocol/SendTo case) hands its link to the already-running
window instead of opening a duplicate. `extractIncomingUrl()`'s parsing/validation logic
has 10 new unit tests (`test/deeplink.test.ts`); typecheck and the full suite are clean.
*Caveat: the OS-level wiring (protocol registration, second-instance routing, the SendTo
shortcut) is main-process/Electron-only and can't be exercised in the Vite browser
preview used elsewhere in this phase — worth a real install + a manual
`aerofetch://download?url=...` / "Send to" smoke test, same spirit as Phases AC's
real-download caveats.*
- [x] **Onboarding / welcome screen** on first run. `Settings.hasCompletedOnboarding`
(default `false`, persisted) gates a full-screen `Onboarding.tsx` rendered in place of
the sidebar + main content in `App.tsx` — not a Fluent `Dialog`, since this app avoids
Fluent's portal-based overlays entirely (GPU/driver blank-overlay issue noted in
`src/main/index.ts` and `Select.tsx`). Shows the brand mark, a one-line pitch, the
download-folder picker (same field as Settings → Downloads), three tip bullets, and a
"Get started" button that flips the flag. Gated on the settings store's `loaded` flag
so a returning user's real settings can never get clobbered by a one-frame flash of the
default-false state. Verified in the UI preview (typecheck + `npm run test` clean):
screen renders, folder field/browse work, "Get started" dismisses into the normal app.
---
## Not portable from Seal (noted for completeness)
- SAF directory picker (Android storage) → already covered by the native folder dialog.
- Android share-sheet / quick-download-on-share → mapped to Phase E's `aerofetch://` protocol
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
for this app's NSIS/portable distribution).
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+26 -8
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,21 +15,36 @@ 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.
# yt-dlp.exe (+ optional aria2c.exe) ship outside the asar archive so they can be spawned
# directly, landing in <install>/resources/bin (reachable via process.resourcesPath).
# ffmpeg.exe/ffprobe.exe are deliberately EXCLUDED — they're the bulk of the installer, so
# they're fetched on first run into userData/bin instead (src/main/ffmpegSetup.ts). The
# excludes also keep a developer's local resources/bin copies (used only for the dev seed)
# out of the shipped build.
extraResources:
- from: resources/bin
to: bin
filter:
- '**/*'
- '!ffmpeg.exe'
- '!ffprobe.exe'
# The app icon, copied so the runtime (system tray) can load it at
# <resources>/icon.ico — build/ itself isn't bundled into the app.
- from: build/icon.ico
to: icon.ico
win:
# Two artifacts:
@@ -42,9 +57,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
@@ -59,4 +74,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
)
+1259 -3
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -1,10 +1,15 @@
{
"name": "aerofetch",
"version": "0.2.0",
"version": "0.7.2",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
"homepage": "https://github.com/yt-dlp/yt-dlp",
"license": "MIT",
"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,24 +19,33 @@
"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",
"@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330",
"@tanstack/react-virtual": "^3.14.3",
"electron-store": "^11.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"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",
@@ -39,7 +53,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"
}
+25 -20
View File
@@ -1,19 +1,21 @@
# Bundled binaries
AeroFetch spawns these from the **main process** and bundles them via
electron-builder `extraResources` (they ship outside the asar archive). The
`.exe` binaries are committed to the repo so a fresh clone builds and runs
without a manual download step.
AeroFetch spawns these from the **main process**. `yt-dlp.exe` (and optional
`aria2c.exe`) ship in the installer via electron-builder `extraResources` (outside the
asar archive) and are committed here so a fresh clone builds and runs.
Drop the three required Windows executables here before running `npm run dev` or
building; `aria2c.exe` is optional:
`ffmpeg.exe` + `ffprobe.exe` are **NOT shipped in the installer** — they're the bulk of
its size, so they're downloaded on first run into `userData/bin` (see
`src/main/ffmpegSetup.ts`, pinned in `src/main/config.ts`). The copies here are used only
as a **dev seed**`ensureManagedFfmpeg` copies them into `userData/bin` so `npm run dev`
needs no download — and are excluded from the shipped build.
```
resources/bin/
├── yt-dlp.exe
├── ffmpeg.exe
├── ffprobe.exe
└── aria2c.exe (optional)
├── yt-dlp.exe (bundled + committed)
├── ffmpeg.exe (dev seed only — excluded from the installer)
├── ffprobe.exe (dev seed only — excluded from the installer)
└── aria2c.exe (optional, bundled)
```
## yt-dlp.exe
@@ -22,17 +24,20 @@ resources/bin/
- Grab `yt-dlp.exe`.
- Unlicense / public domain.
## ffmpeg.exe + ffprobe.exe
## ffmpeg.exe + ffprobe.exe (dev seed only)
- Use an **LGPL** build to keep licensing simple, e.g.
https://github.com/BtbN/FFmpeg-Builds/releases (pick a `*-lgpl-shared` or
`*-lgpl` win64 build) or https://www.gyan.dev/ffmpeg/builds/.
- Copy **both** `ffmpeg.exe` and `ffprobe.exe` here — they ship together in the
same archive's `bin/` folder, and their versions must match. yt-dlp needs
`ffprobe.exe` to read media durations; without it, duration-dependent
post-processing fails with "ffprobe not found" (`--sponsorblock-remove`,
`--force-keyframes-at-cuts`, and `--split-chapters` all error out).
- Ship the LGPL license text alongside the installer before release.
- The app downloads these on first run from the pinned upstream archive in
`src/main/config.ts` (`FFMPEG_ARCHIVE_URL` + `FFMPEG_ARCHIVE_SHA256`). To bump the
version, edit those constants — recompute the SHA-256 from the new zip — in their own
commit (the "pinned decisions" discipline).
- For local dev, drop **both** `ffmpeg.exe` and `ffprobe.exe` here — they come together in
the same archive's `bin/` folder and their versions must match. yt-dlp needs
`ffprobe.exe` to read media durations; without it, duration-dependent post-processing
fails with "ffprobe not found" (`--sponsorblock-remove`, `--force-keyframes-at-cuts`,
and `--split-chapters` all error out). Any recent win64 build works, e.g.
https://www.gyan.dev/ffmpeg/builds/ or https://github.com/BtbN/FFmpeg-Builds/releases.
- The installer no longer redistributes ffmpeg, so there's no ffmpeg license text to ship
with the build — users fetch the binary from upstream themselves.
## aria2c.exe (optional)
Binary file not shown.
+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
+44 -24
View File
@@ -1,13 +1,10 @@
import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { migrateSettingsKeys } from './settingsMigration'
import { listTemplates, replaceTemplates } from './templates'
import type {
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
import { isTemplateLike } from './core/validation'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
interface BackupFile {
version: 1
@@ -16,12 +13,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 }
@@ -31,10 +34,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
@@ -51,16 +55,24 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
// Backups exported by an older AeroFetch carry pre-rename keys (CC1) — map
// them forward so old backup files keep restoring cleanly.
const incomingSettings =
file.settings && typeof file.settings === 'object'
? (file.settings as Partial<Settings>)
? (migrateSettingsKeys(
file.settings as unknown as Record<string, unknown>
) as Partial<Settings>)
: undefined
// Drop non-object / id-less entries up front (audit T3) so both the consent
// check below and replaceTemplates see only well-shaped rows. Without this a
// backup with a null/garbage templates entry throws in sanitize() instead of
// degrading gracefully as this function promises.
const incomingTemplates = Array.isArray(file.templates)
? (file.templates as CommandTemplate[])
? (file.templates as CommandTemplate[]).filter(isTemplateLike)
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
// the next download. A backup that arrives with customCommandEnabled + a default
// the next download. A backup that arrives with enableCustomCommands + a default
// template carrying args would silently enable code execution the moment a
// download starts — so require an explicit, informed confirmation before
// honouring that, and import the templates in a *disabled* state if declined.
@@ -68,7 +80,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
)
const wouldEnableCustomCommands =
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
incomingSettings?.enableCustomCommands === true && commandTemplates.length > 0
let applyCustomCommands = true
if (wouldEnableCustomCommands) {
@@ -80,10 +92,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,
@@ -95,17 +106,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, enableCustomCommands: 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
}
+102 -9
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'
@@ -13,25 +13,118 @@ export function getBinDir(): string {
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
}
export function getYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe')
/**
* The app icon (.ico), for the system tray. In dev it's the repo's build/icon.ico;
* in a packaged build, electron-builder's extraResources copies it to
* <resources>/icon.ico (see electron-builder.yml).
*/
export function getAppIconPath(): string {
return is.dev
? join(app.getAppPath(), 'build', 'icon.ico')
: join(process.resourcesPath, 'icon.ico')
}
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
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
}
/**
* yt-dlp finds ffprobe via --ffmpeg-location (the bin dir), so the app never
* spawns it directly — but it must be present, or duration-aware post-processing
* (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters) fails. This
* accessor exists so startDownload can assert its presence up front.
* AeroFetch keeps its OWN writable copies of the tool binaries under userData/bin,
* separate from anything in the read-only bundle (resources/bin). This managed dir is
* what actually gets spawned, and it survives an app reinstall or portable re-extraction
* (which only ever touch the bundle) — so a self-updated yt-dlp is never rolled back to
* a stale bundled version, and the first-run-downloaded ffmpeg/ffprobe (which the
* installer no longer ships at all) persist across updates.
*
* - yt-dlp.exe — seeded from the bundled copy, then self-updates (`--update-to`).
* See ensureManagedYtdlp in ytdlp.ts.
* - ffmpeg.exe / ffprobe.exe — fetched from upstream on first run (ffmpegSetup.ts);
* in dev, ensureManagedFfmpeg seeds them from resources/bin so `npm run dev` needs
* no download. yt-dlp finds them here via `--ffmpeg-location <getFfmpegDir()>`.
*
* aria2c stays in getBinDir(): it's optional, bundled, and passed by absolute path.
*/
function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
}
/** The bundled, read-only yt-dlp.exe seeded into the managed copy when missing. */
export function getBundledYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe')
}
/** The managed (spawned + auto-updated) yt-dlp.exe under userData. */
export function getYtdlpPath(): string {
return join(getManagedBinDir(), 'yt-dlp.exe')
}
/**
* The managed ffmpeg.exe under userData/bin. The installer no longer bundles ffmpeg
* (it's the bulk of the download); ffmpegSetup.ts fetches it on first run, and in dev
* ensureManagedFfmpeg seeds it from getBundledFfmpegPath(). yt-dlp locates it (and
* ffprobe) via `--ffmpeg-location getFfmpegDir()`.
*/
export function getFfmpegPath(): string {
return join(getManagedBinDir(), 'ffmpeg.exe')
}
/**
* The managed ffprobe.exe under userData/bin. yt-dlp finds it via --ffmpeg-location
* (so the app never spawns it directly), but it must be present, or duration-aware
* post-processing (SponsorBlock-remove, --force-keyframes-at-cuts, --split-chapters)
* fails. startDownload / the setup gate assert its presence up front.
*/
export function getFfprobePath(): string {
return join(getManagedBinDir(), 'ffprobe.exe')
}
/** Directory holding the managed ffmpeg/ffprobe, handed to yt-dlp as --ffmpeg-location. */
export function getFfmpegDir(): string {
return getManagedBinDir()
}
/**
* Dev-only read seeds for ffmpeg/ffprobe in resources/bin, copied into the managed dir
* by ensureManagedFfmpeg when present. Absent in a shipped installer (excluded from the
* bundle in electron-builder.yml), where first-run download provides them instead.
*/
export function getBundledFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
export function getBundledFfprobePath(): 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')
}
/**
* Absolute path to a Windows system executable (e.g. taskkill.exe, schtasks.exe).
*
* SECURITY (audit F3): system tools are resolved by full path under System32
* rather than by bare name, so a same-named binary planted in the current
* working directory or earlier on PATH can't be invoked in their place — a real
* risk for the portable build, which runs from user-writable locations.
*/
export function getSystem32Path(exe: string): string {
return join(process.env.SystemRoot || 'C:\\Windows', 'System32', exe)
}
-295
View File
@@ -1,295 +0,0 @@
/**
* Pure yt-dlp argv construction for a download.
*
* This module is deliberately free of any electron / node-runtime dependency
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
* so the argv it produces can be unit-tested without spinning up Electron.
* `buildArgs` takes the ffmpeg/yt-dlp `binDir` as a parameter rather than
* resolving it itself; the caller in download.ts passes `getBinDir()`.
*/
import { join } from 'path'
import {
BEST_FORMAT_ID,
type StartDownloadOptions,
type DownloadOptions,
type CollectionContext,
type CookieBrowser
} from '@shared/ipc'
// --- Quality → yt-dlp selector mapping --------------------------------------
function videoFormat(quality: string): string {
switch (quality) {
case '1080p':
return 'bv*[height<=1080]+ba/b[height<=1080]'
case '720p':
return 'bv*[height<=720]+ba/b[height<=720]'
case '480p':
return 'bv*[height<=480]+ba/b[height<=480]'
case '360p':
return 'bv*[height<=360]+ba/b[height<=360]'
default:
return 'bv*+ba/b' // Best available
}
}
/**
* The video format selector. A specific probed format_id wins; otherwise fall
* back to the height-based preset. Video-only formats get +bestaudio so the
* merge produces a file with sound (with a video-only fallback if no audio).
*/
function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio
? opts.formatId
: `${opts.formatId}+bestaudio/${opts.formatId}`
}
return videoFormat(opts.quality)
}
function audioQuality(quality: string): string {
switch (quality) {
case '320 kbps':
return '320K'
case '192 kbps':
return '192K'
case '128 kbps':
return '128K'
default:
return '0' // Best
}
}
// 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
// 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|' +
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s'
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
// 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)\'"'
/**
* Phase B "Access & networking" settings — not per-download options, always
* applied from the global settings (there's no per-download override for
* these, unlike DownloadOptions).
*/
export interface AccessOptions {
/** yt-dlp --proxy value; empty disables it */
proxy: string
/** yt-dlp --limit-rate value, e.g. '2M'; empty means unlimited */
rateLimit: string
/** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */
aria2cPath?: string
/** read auth cookies from an installed browser's own cookie store */
cookiesFromBrowser?: CookieBrowser
/** absolute path to a Netscape-format cookie file (the sign-in window's export) */
cookiesFile?: string
/** sanitize output filenames to a portable ASCII-only subset */
restrictFilenames: boolean
/** absolute path to a --download-archive file; omit to disable archive tracking */
downloadArchivePath?: string
}
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
// each at least 1MB so tiny files don't get split pointlessly.
export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
/**
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
* templates) into argv tokens. Supports single- and double-quoted spans so a
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one token.
*
* LIMITATIONS (audit M2): there is no escape-sequence support (`\"`, `\'`) and
* quotes cannot nest — a literal quote inside a value can't be expressed, and an
* unterminated quote falls through to the `\S+` branch and captures the quote
* char itself. For the yt-dlp use cases this targets (proxy URLs, filename
* patterns, ffmpeg recipes), that's sufficient. If a future template needs
* nested or escaped quotes, redesign this to a proper shlex or JSON-based format.
*/
export function parseExtraArgs(raw: string): string[] {
const args: string[] = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
args.push(m[1] ?? m[2] ?? m[3])
}
return args
}
// Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string {
if (arg === '') return '""'
if (/[\s"]/.test(arg)) return `"${arg.replace(/"/g, '\\"')}"`
return arg
}
/** Render a binary + argv as a single copy-pasteable command line. */
export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
// --- Collection (media-manager) folder paths --------------------------------
/**
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
* directory name. This matters because, unlike the filename yt-dlp itself writes
* (which `--restrict-filenames` can clean), these directory segments are built by
* AeroFetch from untrusted channel/playlist titles and joined onto the output
* dir — so they must be neutered for both illegal characters AND path traversal.
*
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
* which also turns a bare `..` traversal segment into nothing
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
* - length-capped so the full path stays well under MAX_PATH
* - empty result falls back to 'Untitled'
*/
export function sanitizeDirSegment(name: string): string {
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
// space. The control-char filter is done by char code so no literal control
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)) s = `_${s}`
s = s.slice(0, 80).trim()
return s || 'Untitled'
}
/**
* Build the `-o` output template for a collection download:
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
* where NNN is the 1-based playlist index zero-padded to three digits and
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
*/
export function collectionOutputTemplate(
outDir: string,
c: CollectionContext,
baseFilename: string
): string {
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
const nnn = String(n).padStart(3, '0')
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim())
if (a.aria2cPath) args.push('--downloader', a.aria2cPath, '--downloader-args', ARIA2C_ARGS)
// cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI
// only ever sets one (driven by Settings.cookieSource), but browser wins if
// a caller somehow sets both.
if (a.cookiesFromBrowser) args.push('--cookies-from-browser', a.cookiesFromBrowser)
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
if (a.restrictFilenames) args.push('--restrict-filenames')
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
return args
}
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
const args: string[] = []
// Subtitles — video only; audio extraction has nowhere to embed them.
if (opts.kind === 'video' && o.embedSubtitles) {
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
if (o.autoSubtitles) args.push('--write-auto-subs')
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
args.push('--convert-subs', 'srt')
}
// SponsorBlock.
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
} else {
args.push('--sponsorblock-mark', cats)
}
}
if (o.embedChapters) args.push('--embed-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
if (o.writeInfoJson) args.push('--write-info-json')
if (o.writeThumbnailFile) args.push('--write-thumbnail')
if (o.writeDescription) args.push('--write-description')
return args
}
export function buildArgs(
opts: StartDownloadOptions,
outputTemplate: string,
o: DownloadOptions,
binDir: string,
access: AccessOptions,
extraArgs: string[] = []
): string[] {
const args = [
'--newline',
'--no-color',
'--no-playlist',
// --print (below) implies --quiet, which would suppress progress; --progress
// forces the progress template to emit anyway.
'--progress',
'--ffmpeg-location',
binDir,
'-o',
outputTemplate,
'--progress-template',
PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later.
'--print',
'after_move:path|%(filepath)s',
'--no-simulate'
]
args.push(...accessArgs(access))
args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') {
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
if (o.embedThumbnail) {
args.push('--embed-thumbnail')
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
}
} else {
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
// pick toward the chosen codec without overriding the requested quality.
if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`)
}
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
}
// Custom-command extra args go last, immediately before the `--` URL
// terminator, so a user-supplied flag can override anything chosen above
// (yt-dlp takes the last occurrence of most options).
if (extraArgs.length > 0) args.push(...extraArgs)
// `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Build/host configuration (CC11): the deploy-specific constants that identify
* WHERE this build talks to, separated from runtime tunables (constants.ts —
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
* fork's update source by editing these three values.
*
* The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous
* reads work and the updater sends no auth at all; there is no secret in the bundle
* to leak. (A prior design split releases into a separate releases-only repo so the
* source could stay private; that's retired now that the source repo is public
* itself — one fewer moving part.) A private fork retargets its updater by editing
* these constants and rebuilding.
*/
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
export const UPDATE_OWNER = 'debont80'
export const UPDATE_REPO = 'AeroFetch'
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
// --- ffmpeg dependency source ------------------------------------------------
// ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size);
// they're fetched once on first run into the managed userData/bin dir (ffmpegSetup.ts).
//
// Unlike the app updater — which pulls from our own host-pinned release server — this
// downloads from UPSTREAM (gyan.dev's GitHub releases). Integrity therefore rests on a
// PINNED exact-version URL + SHA-256 here, re-verified against the streamed bytes: a
// swapped or tampered upstream asset fails the check and nothing is installed. Bumping
// ffmpeg is a deliberate edit to these three constants in its own commit (recompute the
// hash from the new zip) — the same "pinned decisions" discipline the app version follows.
//
// Licensing note: gyan's "essentials" is a GPL build. That's fine here because AeroFetch
// never redistributes it — it invokes ffmpeg.exe as a separate process (as yt-dlp does)
// and the user fetches the binary from upstream themselves. Swapping to an LGPL build
// (e.g. a BtbN win64 build) is purely a change to FFMPEG_ARCHIVE_URL/SHA-256 below,
// provided the zip still carries ffmpeg.exe + ffprobe.exe under a `*/bin/` path.
export const FFMPEG_VERSION = '8.1.2'
export const FFMPEG_ARCHIVE_URL = `https://github.com/GyanD/codexffmpeg/releases/download/${FFMPEG_VERSION}/ffmpeg-${FFMPEG_VERSION}-essentials_build.zip`
export const FFMPEG_ARCHIVE_SHA256 =
'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec'
// The setup UI quotes ~105 MB for this archive; keep that copy in sync when bumping above.
// Hosts the archive download may touch, HTTPS-only, re-checked on EVERY redirect hop
// (github.com 302s a release asset to release-assets.githubusercontent.com; the older
// objects.githubusercontent.com is kept for resilience). Anything off this list fails safe.
export const FFMPEG_TRUSTED_HOSTS = [
'github.com',
'release-assets.githubusercontent.com',
'objects.githubusercontent.com'
] as const
+77
View File
@@ -0,0 +1,77 @@
/**
* 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
/**
* How long the first-run ffmpeg archive download may STALL (no bytes received)
* before it's abandoned (ffmpegSetup.ts). Same generous idle budget the app-update
* download uses; total time is otherwise unbounded (the archive is ~105 MB).
*/
export const FFMPEG_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
/** Unpacking the two ffmpeg exes with System32 tar.exe — bounded so a wedged extract can't hang setup. */
export const FFMPEG_EXTRACT_TIMEOUT_MS = 120_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
/** Saved media profiles (profiles.json). */
export const PROFILES_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()
})
}
+187 -37
View File
@@ -1,30 +1,160 @@
import { app, session, BrowserWindow, type Cookie } 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'
export function getCookiesFilePath(): string {
/**
* The sign-in window renders untrusted remote content, so every navigation and
* popup is confined to web URLs — http(s), plus about:blank. This stops a
* logged-in (or malicious) page from steering the window to a file:// URL, to
* the app's own aerofetch:// protocol handler, or to any other external URI
* scheme used as a pivot. (audit T4)
*/
export function isAllowedLoginUrl(target: string): boolean {
try {
const { protocol } = new URL(target)
return protocol === 'http:' || protocol === 'https:' || protocol === 'about:'
} catch {
return false
}
}
/**
* Apply the web-only confinement to a sign-in webContents: block top-frame
* navigations and server redirects to non-web URLs, restrict popups to web URLs
* (reusing the same secure, partitioned webPreferences), and recurse into any
* popup the page opens so a nested window can't escape the policy either. The
* window-open handler alone only governs NEW windows, not navigations of an
* existing one — both vectors are covered here. (audit T4)
*/
function hardenLoginWebContents(wc: WebContents): void {
wc.setWindowOpenHandler((details) => {
if (!isAllowedLoginUrl(details.url)) return { action: 'deny' }
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: {
partition: LOGIN_PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
}
}
})
wc.on('will-navigate', (e, navUrl) => {
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
})
wc.on('will-redirect', (e, navUrl) => {
if (!isAllowedLoginUrl(navUrl)) e.preventDefault()
})
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
}
// 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()
}
/**
@@ -40,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'
@@ -53,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))
})
@@ -75,7 +214,13 @@ 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,
/** app-theme window background (UI23) — supplied by the IPC layer so the
* sign-in chrome matches the app's Light/Dark instead of flashing white. */
backgroundColor?: string
): Promise<CookiesLoginResult> {
return new Promise((resolve) => {
let validUrl: string
try {
@@ -87,7 +232,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
}
@@ -99,9 +244,15 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
width: 480,
height: 720,
title: 'Sign in — AeroFetch',
// Match the app's resolved theme (UI23) so the window doesn't flash a
// default-white background before the remote page paints.
backgroundColor,
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
@@ -114,34 +265,33 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
}
loginWindow = win
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
// sharing the same partition, rather than silently swallowing the click.
// Restrict popups to http(s) only — the same defence the main window applies
// (index.ts) — so a logged-in page can't open a file:// popup to exfil cookies
// to disk or use a custom-protocol popup as a pivot.
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol !== 'http:' && protocol !== 'https:') return { action: 'deny' }
} catch {
return { action: 'deny' } // unparseable URL — never open it
}
return {
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
}
}
})
// Confine every navigation/popup to web URLs (recursively, so OAuth/SSO
// popups sharing the cookie partition are covered too), and deny all gated
// web permissions — signing in needs no camera/mic/geolocation/etc., and the
// page is untrusted. (audit T4)
hardenLoginWebContents(win.webContents)
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
// '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 */
+518
View File
@@ -0,0 +1,518 @@
/**
* Pure yt-dlp argv construction for a download.
*
* This module is deliberately free of any electron / node-runtime dependency
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
* so the argv it produces can be unit-tested without spinning up Electron.
* `buildArgs` takes the `binDir` as a parameter (used only for `--ffmpeg-location`)
* rather than resolving it itself; download.ts passes `getFfmpegDir()` — the managed
* userData/bin where the first-run download installs ffmpeg/ffprobe.
*/
import { join } from 'path'
import {
BEST_FORMAT_ID,
type StartDownloadOptions,
type DownloadOptions,
type CollectionContext,
type CookieBrowser,
type CommandTemplate
} from '@shared/ipc'
// --- Quality → yt-dlp selector mapping --------------------------------------
function videoFormat(quality: string): string {
switch (quality) {
case '1080p':
return 'bv*[height<=1080]+ba/b[height<=1080]'
case '720p':
return 'bv*[height<=720]+ba/b[height<=720]'
case '480p':
return 'bv*[height<=480]+ba/b[height<=480]'
case '360p':
return 'bv*[height<=360]+ba/b[height<=360]'
default:
return 'bv*+ba/b' // Best available
}
}
/**
* The video format selector. A specific probed format_id wins; otherwise fall
* back to the height-based preset. Video-only formats get +bestaudio so the
* merge produces a file with sound (with a video-only fallback if no audio).
*/
function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
}
return videoFormat(opts.quality)
}
function audioQuality(quality: string): string {
switch (quality) {
case '320 kbps':
return '320K'
case '192 kbps':
return '192K'
case '128 kbps':
return '128K'
case 'Best':
return '0'
default:
// 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|'
// Emitted once at the before_dl stage carrying the resolved FINAL output path, so
// the engine knows the destination stem while the download is still in flight —
// used to delete orphaned .part / .fNNN intermediates when a download is canceled
// (R4). `%(filepath)s` is still unresolved this early (prints 'NA'); `%(filename)s`
// already resolves to the full output path.
export const DEST_MARKER = 'dest|'
// 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 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.
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'
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
// 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)'\""
/**
* Phase B "Access & networking" settings — not per-download options, always
* applied from the global settings (there's no per-download override for
* these, unlike DownloadOptions).
*/
export interface AccessOptions {
/** yt-dlp --proxy value; empty disables it */
proxy: string
/** yt-dlp --limit-rate value, e.g. '2M'; empty means unlimited */
rateLimit: string
/** `--sleep-requests` seconds (pause between extraction requests); 0/omitted = off */
sleepRequests?: number
/** `--sleep-interval` seconds (min pause before each download); 0/omitted = off */
sleepInterval?: number
/** `--max-sleep-interval` seconds (max of the random pre-download pause); applies only with a non-zero sleepInterval */
maxSleepInterval?: number
/** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */
aria2cPath?: string
/** aria2c parallel connections (-x/-s), clamped to 116; omitted = 16 (L64) */
aria2cConnections?: number
/** read auth cookies from an installed browser's own cookie store */
cookiesFromBrowser?: CookieBrowser
/** absolute path to a Netscape-format cookie file (the sign-in window's export) */
cookiesFile?: string
/** sanitize output filenames to a portable ASCII-only subset */
restrictFilenames: boolean
/** absolute path to a --download-archive file; omit to disable archive tracking */
downloadArchivePath?: string
/** YouTube extraction client override (e.g. 'web_safari'); empty/omitted = default */
youtubePlayerClient?: string
/** manually-supplied YouTube Proof-of-Origin token; empty/omitted = none */
youtubePoToken?: string
}
/**
* The yt-dlp `--downloader-args` value for aria2c: N connections split across N
* segments, each at least 1MB so tiny files don't get split pointlessly. The
* connection count is user-tunable (Settings → Network, L64) and clamped here to
* aria2c's own -x/-s ceiling of 16 — settings validation clamps too, but this is
* pure-module defence for any other caller. Non-finite input → the default 16.
*/
export function aria2cArgs(connections?: number): string {
const n =
connections !== undefined && Number.isFinite(connections)
? Math.min(16, Math.max(1, Math.round(connections)))
: 16
return `aria2c:-x ${n} -s ${n} -k 1M`
}
/** The default tuning (16 connections) — kept for tests/docs that reference it. */
export const ARIA2C_ARGS = aria2cArgs()
/**
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
* templates) into argv tokens. Supports single- and double-quoted spans so a
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one token.
*
* LIMITATIONS (audit M2): there is no escape-sequence support (`\"`, `\'`) and
* quotes cannot nest — a literal quote inside a value can't be expressed, and an
* unterminated quote falls through to the `\S+` branch and captures the quote
* char itself. For the yt-dlp use cases this targets (proxy URLs, filename
* patterns, ffmpeg recipes), that's sufficient. If a future template needs
* nested or escaped quotes, redesign this to a proper shlex or JSON-based format.
*/
export function parseExtraArgs(raw: string): string[] {
const args: string[] = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
// 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
}
/**
* Resolve the extra yt-dlp args for a download, enforcing the custom-command
* consent gate (audit F2).
*
* Extra args are powerful enough to run arbitrary code (e.g. `--exec`), so they
* are honoured ONLY when custom commands are explicitly enabled in settings —
* the same persisted flag the Settings UI and backup-import treat as consent. A
* per-download override wins over the persisted default template, but NEITHER is
* applied while the gate is off. This keeps a compromised renderer from smuggling
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
* first have to flip the visible `enableCustomCommands` setting, leaving a trace
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
*
* - enableCustomCommands off → [] (always)
* - perDownloadExtraArgs defined (even '') → those args
* - else a template whose urlPattern matches → that template's args (most specific)
* - else a matching defaultTemplateId → that template's args
* - else → []
*/
export function selectExtraArgs(params: {
enableCustomCommands: boolean
perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
/** the download URL, for urlPattern auto-matching */
url?: string
}): string[] {
if (!params.enableCustomCommands) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
// A template whose urlPattern matches this URL auto-applies, ahead of the global
// default — it's the more specific choice.
if (params.url) {
const matched = params.templates.find(
(t) => t.urlPattern && matchesUrl(t.urlPattern, params.url!)
)
if (matched) return parseExtraArgs(matched.args)
}
if (params.defaultTemplateId) {
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
}
/** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */
export function matchesUrl(pattern: string, url: string): boolean {
try {
return new RegExp(pattern, 'i').test(url)
} catch {
return false
}
}
/**
* Parse a raw trim string (one or more time ranges, comma- or newline-separated)
* into yt-dlp `--download-sections` specs. Each range is normalised to the
* `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a
* timestamp range from a chapter-title regex). Tokens that don't look like a
* numeric time range are dropped, so malformed input can't reach yt-dlp's argv.
*
* Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00',
* '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`.
*/
export function parseTrimSections(raw: string | undefined): string[] {
if (!raw) return []
// 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]/)) {
let t = piece.trim()
if (!t) continue
if (t.startsWith('*')) t = t.slice(1).trim()
if (RANGE.test(t)) out.push(`*${t}`)
}
return out
}
// Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string {
if (arg === '') return '""'
if (/[\s"]/.test(arg)) return `"${arg.replace(/"/g, '\\"')}"`
return arg
}
/** Render a binary + argv as a single copy-pasteable command line. */
export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
// --- Collection (media-manager) folder paths --------------------------------
/**
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
* directory name. This matters because, unlike the filename yt-dlp itself writes
* (which `--restrict-filenames` can clean), these directory segments are built by
* AeroFetch from untrusted channel/playlist titles and joined onto the output
* dir — so they must be neutered for both illegal characters AND path traversal.
*
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
* which also turns a bare `..` traversal segment into nothing
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
* - length-capped so the full path stays well under MAX_PATH
* - empty result falls back to 'Untitled'
*/
export function sanitizeDirSegment(name: string): string {
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
// space. The control-char filter is done by char code so no literal control
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '')
.replace(/^[. ]+/, '')
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
s = s.slice(0, 80).trim()
return s || 'Untitled'
}
/**
* Build the `-o` output template for a collection download. The default layout is
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
* where NNN is the 1-based playlist index zero-padded to three digits and
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
*
* A power user can override the folder layout with a raw yt-dlp `-o` template
* (`Settings.collectionOutputTemplate`, PINCHFLAT output-path setting) — passed
* as `customTemplate`. When present, it's joined under `outDir` and left for
* yt-dlp to expand against ITS own metadata fields (`%(uploader)s`,
* `%(playlist)s`, `%(playlist_index)s`, `%(title)s`, `%(ext)s`, …), so the raw
* template escapes AeroFetch's fixed three-segment layout entirely. It's
* validated as a safe relative template (no absolute path, no `..`) before it
* ever reaches here.
*/
export function collectionOutputTemplate(
outDir: string,
c: CollectionContext,
baseFilename: string,
customTemplate?: string
): string {
const custom = customTemplate?.trim()
if (custom) return join(outDir, custom)
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
const nnn = String(n).padStart(3, '0')
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim())
// Request throttle — the antidote to YouTube's rate-limit/403 wall on big
// channel pulls. `--sleep-requests` spaces out the extraction requests; the
// sleep-interval pair brackets a random pause before each download. yt-dlp
// requires --sleep-interval to be present for --max-sleep-interval to be
// meaningful, and a max below the min is nonsensical, so max is gated on both.
const sleepRequests = a.sleepRequests ?? 0
if (sleepRequests > 0) args.push('--sleep-requests', String(sleepRequests))
const sleepInterval = a.sleepInterval ?? 0
if (sleepInterval > 0) {
args.push('--sleep-interval', String(sleepInterval))
const maxSleep = a.maxSleepInterval ?? 0
if (maxSleep > sleepInterval) args.push('--max-sleep-interval', String(maxSleep))
}
if (a.aria2cPath) {
args.push('--downloader', a.aria2cPath, '--downloader-args', aria2cArgs(a.aria2cConnections))
}
// cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI
// only ever sets one (driven by Settings.cookieSource), but browser wins if
// a caller somehow sets both.
if (a.cookiesFromBrowser) args.push('--cookies-from-browser', a.cookiesFromBrowser)
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
if (a.restrictFilenames) args.push('--restrict-filenames')
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
// YouTube reliability: combine the player-client + PO-token overrides into one
// `youtube:` extractor-args group (semicolon-separated, as yt-dlp expects).
const yt: string[] = []
if (a.youtubePlayerClient?.trim()) yt.push(`player_client=${a.youtubePlayerClient.trim()}`)
if (a.youtubePoToken?.trim()) yt.push(`po_token=${a.youtubePoToken.trim()}`)
if (yt.length > 0) args.push('--extractor-args', `youtube:${yt.join(';')}`)
return args
}
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
const args: string[] = []
// Subtitles — video only; audio extraction has nowhere to embed them.
if (opts.kind === 'video' && o.embedSubtitles) {
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
if (o.autoSubtitles) args.push('--write-auto-subs')
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
args.push('--convert-subs', 'srt')
}
// SponsorBlock.
let forcedKeyframes = false
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
forcedKeyframes = true
} else {
args.push('--sponsorblock-mark', cats)
}
}
// Trim — download only the given time ranges (works for video + audio). Force
// keyframes at the cut points for frame-accurate boundaries, unless
// SponsorBlock-remove already requested it (the flag is idempotent, but we
// avoid emitting it twice).
const sections = parseTrimSections(opts.trim)
for (const sec of sections) args.push('--download-sections', sec)
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
if (o.embedChapters) args.push('--embed-chapters')
// Split into one file per chapter. yt-dlp also keeps the full file; the
// per-chapter files use yt-dlp's default `chapter:` output template.
if (o.splitChapters) args.push('--split-chapters')
// 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.
if (o.writeInfoJson) args.push('--write-info-json')
if (o.writeThumbnailFile) args.push('--write-thumbnail')
if (o.writeDescription) args.push('--write-description')
return args
}
/**
* 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',
'--ffmpeg-location',
binDir,
'-o',
outputTemplate,
'--progress-template',
PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later.
'--print',
`after_move:${FILEPATH_MARKER}%(filepath)s`,
// Print the resolved destination up front (before_dl) so a cancel can find and
// delete this download's orphaned partials by stem, mid-flight (R4).
'--print',
`before_dl:${DEST_MARKER}%(filename)s`,
'--no-simulate'
]
args.push(...accessArgs(access))
args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') {
// --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)
}
} else {
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// A raw format-sort string (advanced) wins outright; otherwise the codec
// preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
// the chosen codec without overriding the requested quality.
const sort = o.formatSort.trim()
if (sort) {
args.push('-S', sort)
} else if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`)
}
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
}
// Custom-command extra args go last, immediately before the `--` URL
// terminator, so a user-supplied flag can override anything chosen above
// (yt-dlp takes the last occurrence of most options).
if (extraArgs.length > 0) args.push(...extraArgs)
// `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args
}
@@ -1,6 +1,6 @@
/**
* Pure, dependency-free core for Source indexing (Pinchflat-style media manager;
* see ROADMAP-PINCHFLAT.md). Like buildArgs.ts / validation.ts this module imports
* Pure, dependency-free core for Source indexing (Pinchflat-style media
* manager). Like buildArgs.ts / validation.ts this module imports
* nothing from electron or the node runtime, so its URL-classification and
* item-merge logic can be unit-tested without spinning up Electron.
*
@@ -34,7 +34,9 @@ export interface RawEntry {
export function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
// e.id is untrusted JSON from yt-dlp, so percent-encode it rather than splicing
// it raw into the query string (audit T2). A normal 11-char id is unaffected.
if (e.id) return `https://www.youtube.com/watch?v=${encodeURIComponent(e.id)}`
return null
}
@@ -86,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
@@ -138,12 +143,30 @@ export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string
return `https://www.youtube.com/feeds/videos.xml?${param}=${encodeURIComponent(ytId)}`
}
/**
* Guard for the watched-source RSS pre-check (audit T7). The only feed AeroFetch
* ever builds is a YouTube videos.xml feed (see buildFeedUrl), so the sync refuses
* to fetch anything else this keeps a hand-edited / corrupted sources.json from
* pointing fetch() at an internal service, a cloud-metadata endpoint, or any other
* arbitrary host (SSRF). Requires https, the youtube.com host (www optional), and
* the exact feed path; the channel_id/playlist_id query is free.
*/
export function isYouTubeFeedUrl(url: string): boolean {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
return u.protocol === 'https:' && host === 'youtube.com' && u.pathname === '/feeds/videos.xml'
} catch {
return false
}
}
/** Extract the video ids from a YouTube RSS/Atom feed body (the <yt:videoId> tags). */
export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = []
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
}
@@ -199,7 +222,7 @@ export function buildMediaItems(
/**
* Merge a freshly-enumerated item list with the previously-persisted one for the
* same source (incremental re-index; see ROADMAP-PINCHFLAT.md Phase I). The fresh
* same source (incremental re-index). The fresh
* list defines the current membership/ordering, but any video already marked
* downloaded keeps its `downloaded`/`downloadedAt`/`filePath` so a re-sync never
* loses that state or re-downloads it. Videos that vanished from the source (now
@@ -7,21 +7,34 @@
*/
import { isAbsolute } from 'path'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem } from '@shared/ipc'
import type {
HistoryEntry,
ErrorLogEntry,
CommandTemplate,
MediaProfile,
Source,
MediaItem
} from '@shared/ipc'
// --- Path-traversal sanitization (audit S4) ---------------------------------
/**
* A filenameTemplate is joined onto the output directory and handed to yt-dlp's
* `-o`. Reject anything that could write outside that directory an absolute
* path, or any `..` path segment so a malicious backup/settings write can't
* traverse out of the chosen folder (e.g. '%(title)s\..\..\win32.exe'). The
* template still legitimately contains yt-dlp `%(field)s` tokens and `/` or `\`
* for sub-folders, which are fine.
* `-o`. Reject anything that could write outside that directory so a malicious
* backup/settings write can't traverse out of the chosen folder (e.g.
* '%(title)s\..\..\win32.exe'). Legitimate templates still contain yt-dlp
* `%(field)s` tokens and `/` or `\` for sub-folders, which are fine.
*
* Two Windows-specific bypasses are guarded beyond the obvious cases (audit T1):
* - drive-relative prefixes like 'C:foo' `path.isAbsolute` returns FALSE for
* these, yet they escape the output dir, so a leading drive letter is rejected.
* - a '..' segment dressed up with trailing dots/spaces ('.. ', '.. .')
* Windows silently trims those, so an exact `=== '..'` check would miss them.
*/
const TRAVERSAL_SEGMENT = /^[. ]*\.\.[. ]*$/
export function isSafeFilenameTemplate(template: string): boolean {
if (isAbsolute(template)) return false
return !template.split(/[\\/]/).some((segment) => segment === '..')
if (isAbsolute(template) || /^[a-zA-Z]:/.test(template)) return false
return !template.split(/[\\/]/).some((segment) => TRAVERSAL_SEGMENT.test(segment))
}
/** An output directory must be an absolute path ('' resolves to OS Downloads). */
@@ -61,7 +74,8 @@ export function isValidErrorLogEntry(o: unknown): o is ErrorLogEntry {
(e.kind === 'video' || e.kind === 'audio') &&
typeof e.error === 'string' &&
typeof e.occurredAt === 'number' &&
isOptionalString(e.title)
isOptionalString(e.title) &&
isOptionalString(e.detail)
)
}
@@ -76,6 +90,17 @@ export function isTemplateLike(o: unknown): o is CommandTemplate {
return typeof t.id === 'string' || typeof t.id === 'number'
}
/**
* A persisted profiles.json row must at least be an object carrying an id; the
* rest is coerced by profiles.ts's sanitize(), so a weak shape check plus
* normalisation there is enough (same approach as isTemplateLike).
*/
export function isProfileLike(o: unknown): o is MediaProfile {
if (!o || typeof o !== 'object') return false
const p = o as Record<string, unknown>
return typeof p.id === 'string' || typeof p.id === 'number'
}
/** A persisted sources.json row must have the right shape or it's dropped on read. */
export function isValidSource(o: unknown): o is Source {
if (!o || typeof o !== 'object') return false
@@ -88,6 +113,10 @@ export function isValidSource(o: unknown): o is Source {
typeof s.addedAt === 'number' &&
typeof s.itemCount === 'number' &&
isOptionalString(s.channel) &&
// feedUrl drives a network fetch in the sync, so validate its shape here too
// (audit T7); the host is additionally restricted at the fetch boundary.
isOptionalString(s.feedUrl) &&
(s.watched === undefined || typeof s.watched === 'boolean') &&
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
)
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Pure auto-update scheduling policy for yt-dlp. Kept free of any electron /
* node-runtime dependency (like buildArgs.ts) so the throttle can be unit-tested
* without spinning up Electron. ytdlp.ts imports these to drive the real check.
*/
/** Once-a-day throttle for the startup auto-update check. */
export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000
/**
* Whether a background yt-dlp update check should run now: only when auto-update
* is enabled AND at least one interval has elapsed since the last check. A
* zero/absent lastCheck means "never checked" → run. `now` and the interval are
* passed in so the decision is a pure function of its inputs.
*/
export function shouldAutoCheckYtdlp(
enabled: boolean,
lastCheck: number,
now: number,
intervalMs: number = AUTO_UPDATE_INTERVAL_MS
): boolean {
if (!enabled) return false
if (!lastCheck) return true
// 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
}
+23 -9
View File
@@ -1,27 +1,41 @@
import { app, shell, type BrowserWindow } from 'electron'
import { existsSync, readFileSync } from 'fs'
import { existsSync, openSync, readSync, closeSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
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. */
* external-link window-open handler in index.ts applies to in-page links.
* Delegates to the download path's guard so an incoming deep-link target is
* validated AND parser-normalised (no embedded tabs/newlines/control chars
* reach the renderer); returns null instead of throwing for the argv scan.
* (audit T3 / F5) */
function asHttpUrl(candidate: string): string | null {
try {
const u = new URL(candidate)
return u.protocol === 'http:' || u.protocol === 'https:' ? candidate : null
return assertHttpUrl(candidate)
} catch {
return null
}
}
/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's
* "Send to" menu hands us when the user sends a saved link to AeroFetch. */
* "Send to" menu hands us when the user sends a saved link to AeroFetch.
* Only the first 64 KB is read: a real shortcut is a few hundred bytes, so this
* caps memory and limits exposure if argv points at a pathological or oversized
* file that merely ends in `.url`. (audit T5) */
const MAX_URL_FILE_BYTES = 64 * 1024
function readUrlShortcut(path: string): string | null {
let fd: number | null = null
try {
const text = readFileSync(path, 'utf8')
const match = /^URL=(.+)$/im.exec(text)
return match ? asHttpUrl(match[1].trim()) : null
fd = openSync(path, 'r')
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
const bytes = readSync(fd, buf, 0, buf.length, 0)
const url = parseUrlShortcutContent(buf.toString('utf8', 0, bytes))
return url ? asHttpUrl(url) : null
} catch {
return null
} finally {
if (fd !== null) closeSync(fd)
}
}
@@ -32,7 +46,7 @@ function readUrlShortcut(path: string): string | null {
*/
export function extractIncomingUrl(argv: string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('aerofetch://')) {
if (/^aerofetch:\/\//i.test(arg)) {
try {
const target = new URL(arg).searchParams.get('url')
const valid = target && asHttpUrl(target)
+439 -162
View File
@@ -1,20 +1,46 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { getCookiesFilePath } from './cookies'
import { existsSync, unlinkSync, readdirSync } from 'fs'
import { tmpdir } from 'os'
import { join, parse } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
getYtdlpPath,
getFfmpegDir,
getAria2cPath,
getFfmpegPath,
getFfprobePath,
getSystem32Path,
getAppIconImage,
YTDLP_MISSING_MSG
} from './binaries'
import { getSettings } from './settings'
import { execFileAsync } from './lib/exec'
import { createLineBuffer } from './lib/lineBuffer'
import { orphanPartials } from './lib/partials'
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
import { ensureManagedYtdlp } from './ytdlp'
import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './core/validation'
import {
buildArgs,
parseExtraArgs,
selectExtraArgs,
formatCommandLine,
collectionOutputTemplate
} from './buildArgs'
import { cleanError } from './log'
collectionOutputTemplate,
PROGRESS_MARKER,
FILEPATH_MARKER,
DEST_MARKER
} from './core/buildArgs'
import { describeDownloadError, stderrDetail } 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,
@@ -22,75 +48,58 @@ import {
type CommandPreviewResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress,
type Settings
} from '@shared/ipc'
interface ActiveDownload {
child: ChildProcess
canceled: boolean
paused: boolean
}
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
* spawned processes) when the user closes the window mid-download.
*/
export function hasActiveDownloads(): boolean {
return active.size > 0
}
// --- 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)
@@ -101,110 +110,156 @@ 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 {
function logFailure(
opts: StartDownloadOptions,
title: string | undefined,
error: string,
detail?: 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,
url: opts.url,
kind: opts.kind,
error,
detail,
occurredAt: Date.now()
})
}
// --- 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) ---
// A per-download override (opts.extraArgs, even '') always wins; otherwise
// fall back to the persisted default template when custom-command mode is on.
// A per-download override (opts.extraArgs, even '') wins over the persisted
// default template — but BOTH are gated on the enableCustomCommands consent flag
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
// in the renderer UI, so the renderer can't be trusted to apply it.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
if (opts.extraArgs !== undefined) return parseExtraArgs(opts.extraArgs)
if (settings.customCommandEnabled && settings.defaultTemplateId) {
const tpl = listTemplates().find((t) => t.id === settings.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
// Gate the file read: listTemplates() parses templates.json on every call, so
// skip it entirely when the feature is off (PERF2).
if (!settings.enableCustomCommands) return []
return selectExtraArgs({
enableCustomCommands: settings.enableCustomCommands,
perDownloadExtraArgs: opts.extraArgs,
defaultTemplateId: settings.defaultTemplateId,
templates: listTemplates(),
url: opts.url
})
}
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
/** 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()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
// Output dir resolution: a per-download override wins, then the user's explicit
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
//
// A per-download outputDir override must clear the same safety check the persisted
// setting does (absolute path only); a renderer-supplied override is otherwise
// untrusted — it dictates where downloaded files get written. An unsafe override is
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
const override = opts.outputDir?.trim()
const safeOverride = override && isSafeOutputDir(override) ? override : ''
const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim()
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
// A collection (media-manager) download is filed into <channel>/<playlist>/
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
// <NNN> - <title> folders by default, or into a power-user's raw yt-dlp `-o`
// layout (Settings.collectionOutputTemplate); an ordinary download uses the
// flat filenameTemplate.
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
const outputTemplate = opts.collection
? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s')
? collectionOutputTemplate(
outDir,
opts.collection,
'%(title)s.%(ext)s',
// A per-download override (a Media Profile's outputTemplate) wins over the
// global collection-layout setting.
opts.collectionOutputTemplate ?? settings.collectionOutputTemplate
)
: join(outDir, filenameTemplate)
// Per-download override wins; otherwise use the persisted defaults.
const options = opts.options ?? settings.downloadOptions
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
// 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,
sleepRequests: settings.sleepRequests,
sleepInterval: settings.sleepInterval,
maxSleepInterval: settings.maxSleepInterval,
aria2cPath,
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
aria2cConnections: settings.aria2cConnections,
// 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
downloadArchivePath: settings.useDownloadArchive ? getDownloadArchivePath() : undefined,
youtubePlayerClient: settings.youtubePlayerClient,
youtubePoToken: settings.youtubePoToken
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
// binDir feeds only `--ffmpeg-location`, so it's the managed ffmpeg dir (userData/bin),
// where the first-run download / dev seed places ffmpeg.exe + ffprobe.exe.
return buildArgs({ opts, outputTemplate, options, binDir: getFfmpegDir(), access, extraArgs })
}
/** Build the exact command line for the current form state, without running it. */
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
let normalized: StartDownloadOptions
try {
assertHttpUrl(opts.url)
// Use the parser-normalised URL (audit F5), so the previewed command matches
// exactly what startDownload would spawn.
normalized = { ...opts, url: assertHttpUrl(opts.url) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
try {
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(opts)) }
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(normalized)) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -212,15 +267,15 @@ 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()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
error: YTDLP_MISSING_MSG
}
}
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
@@ -232,16 +287,20 @@ export function startDownload(
if (!existsSync(getFfmpegPath())) missingBins.push('ffmpeg.exe')
if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe')
if (missingBins.length > 0) {
// The first-run setup gate normally guarantees these are present; this backstops a
// mid-session loss (AV quarantine, a cleared userData/bin) with a clear repair path.
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. Open Settings → About and choose "Repair ffmpeg" to reinstall the media tools.`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
// and replace opts.url with the parser-normalised form so the exact string we
// validated is the one that gets spawned/probed — not a raw variant carrying
// interior tabs/newlines or leading control chars that URL parsing silently
// tolerates. (audit F5)
try {
assertHttpUrl(opts.url)
opts = { ...opts, url: assertHttpUrl(opts.url) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -255,15 +314,52 @@ export function startDownload(
if (active.size >= maxConcurrent) {
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
}
// L139: don't launch the managed yt-dlp while a self-update is rewriting the exe —
// on Windows that races into a sharing violation or a half-written binary. The
// updater backs off whenever a download is live and only holds the lock for the
// brief rewrite, so this is a rare, retryable refusal.
if (isYtdlpUpdating()) {
return {
ok: false,
error: 'yt-dlp is updating in the background — try this download again in a moment.'
}
}
// 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 }
}
// L139: a live yt-dlp process now exists — the updater must defer to it until the
// matching releaseSpawn() on settle (close/error/stall) below.
acquireSpawn()
const rec: ActiveDownload = { child, canceled: false }
const rec: ActiveDownload = { child, canceled: false, paused: false }
active.set(opts.id, rec)
// Title/channel/duration are kept locally so the completion notification and
@@ -279,74 +375,255 @@ 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
// The resolved FINAL output path (from the before_dl `dest|` print), captured
// while the download is still running so a cancel can delete this download's
// orphaned partials by stem (R4). Undefined until the first stream starts.
let outputPath: 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)
releaseSpawn()
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()
} else if (line.startsWith(DEST_MARKER)) {
// before_dl fires once per stream; every emission carries the same final
// path, so the last-write-wins overwrite is harmless.
outputPath = line.slice(DEST_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)
if (!rec.canceled) {
clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
releaseSpawn()
// A canceled download's partials are now orphans — remove them (R4). A paused
// download was also killed on purpose, but keeps its .part for a later resume.
if (rec.canceled) cleanupPartials(outputPath)
// 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)
if (rec.canceled) return // renderer already showed 'canceled' optimistically
clearWatchdog()
cleanup()
releaseActive(opts.id, rec)
releaseSpawn()
// Canceled: renderer already showed 'canceled'; the process is now gone (its
// file handles released), so delete this download's orphaned partials (R4).
// Paused: renderer showed 'paused' and KEEPS the .part for a later resume.
// Either way, no event.
if (rec.canceled) {
cleanupPartials(outputPath)
return
}
if (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}`
const msg = describeDownloadError(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, stderrDetail(stderrTail))
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
// taskkill from System32 by absolute path, never the bare name, so a planted
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
function killTree(rec: ActiveDownload): void {
const pid = rec.child.pid
if (pid != null) {
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
rec.child.kill()
}
}
/**
* Delete a canceled download's orphaned intermediate files (R4). `outputPath` is the
* resolved FINAL destination captured from the before_dl `dest|` print; orphanPartials
* (pure, unit-tested in lib/partials.ts) turns its dir listing into the exact set of
* this download's `.part` / `.part-Frag*` / `.ytdl` / `.fNNN.<ext>` intermediates —
* never a completed `<stem>.<ext>` or another download's files. Best-effort: a
* still-locked or already-gone entry is skipped. Called from the child's close/error
* handler, so the process (and its file handles) are already gone.
*/
function cleanupPartials(outputPath: string | undefined): void {
if (!outputPath) return
const { dir } = parse(outputPath)
if (!dir) return
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return // folder vanished / unreadable — nothing to clean
}
for (const entry of orphanPartials(outputPath, entries)) {
try {
unlinkSync(join(dir, entry))
} catch {
/* best-effort: locked or already removed */
}
}
}
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
const pid = rec.child.pid
if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too.
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {})
} else {
rec.child.kill()
}
// 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)
}
/**
* Pause a running download: kill the process tree but flag it `paused` (not
* `canceled`) so the close handler stays silent — no error event, no error log,
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
* is just a fresh startDownload with the same options: yt-dlp's default
* `--continue` picks the partial file back up.
*/
export function pauseDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.paused = true
// 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)
}
+11 -33
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 { isValidErrorLogEntry } from './core/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([])
}
+31
View File
@@ -0,0 +1,31 @@
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'
/**
* Read the version token from an ffmpeg/ffprobe binary. Both print a first line
* of the form "<tool> version <VERSION> Copyright ..." (e.g.
* "ffmpeg version 6.1.1-full_build-www.gyan.dev Copyright ..."), so we return the
* token after "version". Resolves null if the binary is absent, errors, times
* out, or the line doesn't parse — Settings then shows "not found" instead of
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
*/
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 managed ffmpeg + ffprobe, read in parallel for the Settings panel. */
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
const [ffmpeg, ffprobe] = await Promise.all([
readToolVersion(getFfmpegPath()),
readToolVersion(getFfprobePath())
])
return { ffmpeg, ffprobe }
}
+249
View File
@@ -0,0 +1,249 @@
import { app, dialog, type BrowserWindow, type OpenDialogOptions, type WebContents } from 'electron'
import { existsSync, mkdirSync, copyFileSync, renameSync, rmSync } from 'fs'
import { execFile } from 'child_process'
import { join, dirname, basename } from 'path'
import {
getFfmpegPath,
getFfprobePath,
getFfmpegDir,
getBundledFfmpegPath,
getBundledFfprobePath,
getSystem32Path
} from './binaries'
import { getFfmpegVersions } from './ffmpeg'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { FFMPEG_ARCHIVE_URL, FFMPEG_ARCHIVE_SHA256, FFMPEG_TRUSTED_HOSTS } from './config'
import { VERSION_TIMEOUT_MS, FFMPEG_EXTRACT_TIMEOUT_MS } from './constants'
import {
IpcChannels,
type FfmpegSetupStatus,
type FfmpegSetupProgress,
type FfmpegSetupResult
} from '@shared/ipc'
import { logger } from './logger'
/**
* First-run acquisition of ffmpeg + ffprobe. The installer no longer bundles them
* (they're the bulk of its size); they live in the managed userData/bin dir (like the
* self-updating yt-dlp) and are fetched once on first run from a PINNED upstream archive,
* verified against a pinned SHA-256, then unpacked with the OS's tar.exe. See config.ts
* for the source + trust rationale, and binaries.ts for why the managed dir is used.
*/
/**
* Trust gate for the ffmpeg archive: HTTPS + an allowlisted upstream host, re-checked on
* every redirect hop by streamVerifiedFile (github.com 302s to a githubusercontent host).
* Mirrors updater.ts's isTrustedDownloadUrl but for the upstream host set, not our own.
*/
export function isTrustedFfmpegUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && (FFMPEG_TRUSTED_HOSTS as readonly string[]).includes(u.host)
} catch {
return false
}
}
/** Both binaries present in the managed dir — the readiness the download gate keys on. */
function bothPresent(): boolean {
return existsSync(getFfmpegPath()) && existsSync(getFfprobePath())
}
/** Copy a dev seed into the managed dir when the managed copy is missing. */
function seedOne(seed: string, managed: string): void {
if (existsSync(managed) || !existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent → first-run download */
}
}
/**
* Ensure the managed ffmpeg/ffprobe exist, seeding them from the read-only bundle when
* present (dev checkouts keep them in resources/bin, so `npm run dev` needs no download).
* A shipped installer carries no seed, so this no-ops and first-run download provides them.
* Best-effort and idempotent — cheap to call at startup and before the presence gate.
* Mirrors ensureManagedYtdlp in ytdlp.ts.
*/
export function ensureManagedFfmpeg(): void {
seedOne(getBundledFfmpegPath(), getFfmpegPath())
seedOne(getBundledFfprobePath(), getFfprobePath())
}
/** Readiness + versions for the setup gate and the About card. */
export async function getFfmpegSetupStatus(): Promise<FfmpegSetupStatus> {
// Seed from the dev bundle first so a seeded checkout reports ready without a download.
ensureManagedFfmpeg()
const versions = await getFfmpegVersions()
return { ready: bothPresent(), ffmpeg: versions.ffmpeg, ffprobe: versions.ffprobe }
}
// The in-flight download's canceller (armed only during the network phase; extraction is
// not cancelable) and a single-flight guard shared by download + locate.
let activeCancel: (() => void) | null = null
let extracting = false
/** Abort the in-flight ffmpeg archive download, if any (the setup card's Cancel). */
export function cancelFfmpegDownload(): void {
activeCancel?.()
}
/**
* Download the pinned ffmpeg archive, verify its SHA-256, unpack ffmpeg.exe + ffprobe.exe
* into the managed dir, and clean up. Progress (download %, then an indeterminate
* "extracting" phase) is pushed to the renderer. Single-flight; the network phase is
* cancelable via cancelFfmpegDownload().
*/
export async function downloadFfmpeg(wc: WebContents): Promise<FfmpegSetupResult> {
if (activeCancel || extracting) {
return { ok: false, error: 'An ffmpeg download is already in progress.' }
}
if (!isTrustedFfmpegUrl(FFMPEG_ARCHIVE_URL)) {
return { ok: false, error: 'Refused to download ffmpeg from an untrusted location.' }
}
const tempZip = join(app.getPath('temp'), 'aerofetch-ffmpeg.zip')
const extractDir = join(app.getPath('temp'), `aerofetch-ffmpeg-${Date.now()}`)
const send = (p: FfmpegSetupProgress): void => {
if (!wc.isDestroyed()) wc.send(IpcChannels.ffmpegSetupProgress, p)
}
try {
const dl = await streamVerifiedFile({
url: FFMPEG_ARCHIVE_URL,
filePath: tempZip,
expectedSha: FFMPEG_ARCHIVE_SHA256,
isTrusted: isTrustedFfmpegUrl,
onProgress: (p) =>
send({ phase: 'downloading', received: p.received, total: p.total, fraction: p.fraction }),
onCancelReady: (cancel) => {
activeCancel = cancel
},
onSettled: (cancel) => {
if (activeCancel === cancel) activeCancel = null
},
messages: {
checksumMismatch:
'The ffmpeg download failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
canceled: 'ffmpeg download canceled.'
}
})
if (!dl.ok) return { ok: false, error: dl.error }
extracting = true
send({ phase: 'extracting' })
await extractArchive(tempZip, extractDir)
installFromDir(extractDir)
if (!bothPresent()) {
return { ok: false, error: 'The ffmpeg archive did not contain the expected binaries.' }
}
return { ok: true }
} catch (e) {
logger.error('[ffmpeg-setup] download failed', e instanceof Error ? e.message : String(e))
return { ok: false, error: e instanceof Error ? e.message : String(e) }
} finally {
extracting = false
activeCancel = null
rmSync(tempZip, { force: true })
rmSync(extractDir, { recursive: true, force: true })
}
}
/**
* Unpack just ffmpeg.exe + ffprobe.exe (flattened) from the archive using the OS's bundled
* bsdtar. Resolve tar.exe by absolute System32 path, never the bare name, so a planted
* tar.exe on PATH / in the CWD can't run in its place (audit F3). The two glob patterns
* (matching the `bin/ffmpeg.exe` + `bin/ffprobe.exe` members) are applied by tar itself —
* execFile does no shell globbing — and --strip-components=2 drops the leading
* `<build>/bin/` path so the exes land flat in destDir.
*/
function extractArchive(zip: string, destDir: string): Promise<void> {
mkdirSync(destDir, { recursive: true })
return new Promise((resolve, reject) => {
execFile(
getSystem32Path('tar.exe'),
['-xf', zip, '-C', destDir, '--strip-components=2', '*/bin/ffmpeg.exe', '*/bin/ffprobe.exe'],
{ timeout: FFMPEG_EXTRACT_TIMEOUT_MS, windowsHide: true },
(err) => (err ? reject(err) : resolve())
)
})
}
/** Move the two extracted exes into the managed dir. */
function installFromDir(dir: string): void {
mkdirSync(getFfmpegDir(), { recursive: true })
installOne(join(dir, 'ffmpeg.exe'), getFfmpegPath())
installOne(join(dir, 'ffprobe.exe'), getFfprobePath())
}
/**
* Place one binary at its managed path via a temp copy + rename, so a concurrent read
* never sees a half-written exe (rename is atomic within a volume). Falls back to an
* in-place overwrite only if the rename is blocked by a same-name destination.
*/
function installOne(src: string, dest: string): void {
if (!existsSync(src)) throw new Error(`the ffmpeg archive is missing ${basename(dest)}`)
const tmp = `${dest}.part`
copyFileSync(src, tmp)
try {
renameSync(tmp, dest)
} catch {
copyFileSync(tmp, dest)
rmSync(tmp, { force: true })
}
}
/** Run `<tool> -version` to confirm a manually-located binary is actually runnable. */
function validateTool(path: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(
path,
['-version'],
{ timeout: VERSION_TIMEOUT_MS, windowsHide: true },
(err, stdout) => resolve(!err && /version/i.test(String(stdout)))
)
})
}
/**
* Offline / air-gapped fallback: let the user point at a folder that already holds
* ffmpeg.exe + ffprobe.exe (or its `bin/` subfolder), validate both actually run, and
* copy them into the managed dir. This is what keeps the hard first-run gate escapable
* without a network connection.
*/
export async function locateFfmpegManually(win?: BrowserWindow): Promise<FfmpegSetupResult> {
if (activeCancel || extracting) {
return { ok: false, error: 'An ffmpeg download is already in progress.' }
}
const opts: OpenDialogOptions = {
properties: ['openDirectory'],
title: 'Select the folder containing ffmpeg.exe and ffprobe.exe'
}
const res = win ? await dialog.showOpenDialog(win, opts) : await dialog.showOpenDialog(opts)
if (res.canceled || !res.filePaths[0]) return { ok: false, error: 'No folder selected.' }
const chosen = res.filePaths[0]
for (const cand of [chosen, join(chosen, 'bin')]) {
const ffmpeg = join(cand, 'ffmpeg.exe')
const ffprobe = join(cand, 'ffprobe.exe')
if (!existsSync(ffmpeg) || !existsSync(ffprobe)) continue
const runnable = (await validateTool(ffmpeg)) && (await validateTool(ffprobe))
if (!runnable) {
return {
ok: false,
error: 'Those binaries could not be run — they may be blocked or corrupt.'
}
}
try {
mkdirSync(getFfmpegDir(), { recursive: true })
installOne(ffmpeg, getFfmpegPath())
installOne(ffprobe, getFfprobePath())
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
return { ok: true }
}
return { ok: false, error: "That folder doesn't contain ffmpeg.exe and ffprobe.exe." }
}
+21 -41
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 { isValidHistoryEntry } from './validation'
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './core/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([])
}
+231 -202
View File
@@ -1,38 +1,28 @@
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { app, shell, BrowserWindow, nativeTheme, dialog, 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
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } 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 { ensureManagedFfmpeg } from './ffmpegSetup'
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
@@ -40,62 +30,116 @@ import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
const isPrimaryInstance = app.requestSingleInstanceLock()
if (!isPrimaryInstance) app.quit()
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
// Chromium's GPU compositor renders blank: under hardware acceleration the WHOLE window
// paints white (popup overlays alone were blank earlier). Updating the NVIDIA driver to
// the last Fermi release did not fix it. Software compositing renders correctly and
// costs no meaningful performance for this lightweight UI, so it is the safe default for
// this app's target hardware. The only downside is a minor overlay-repaint flicker that
// appears on the most broken GPUs. The window backgroundColor is theme-matched (see
// createWindow); native window-occlusion tracking is disabled (a separate documented
// Windows blank/flicker cause). Must run before the app is ready.
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
// Redirect user data next to the exe when possible (portable, no-admin). Must run
// before the app is ready and before any user path is read.
// before the app is ready and before any user path is read — including the
// settings read for the hardware-acceleration gate just below.
setupPortableData()
// Software compositing (GPU acceleration off) is the DEFAULT, now behind an
// opt-in setting (W15). On this dev machine's old GeForce GT 625 — and plausibly
// on the old / locked-down public PCs this app targets — Chromium's GPU
// compositor renders blank: under hardware acceleration the WHOLE window paints
// white (popup overlays alone were blank earlier). Updating the NVIDIA driver to
// the last Fermi release did not fix it. Software compositing renders correctly
// and costs no meaningful performance for this lightweight UI, so it stays the
// safe default; users on modern GPUs can flip Settings → Appearance →
// "Hardware acceleration" (applies on next launch — this must run before the
// app is ready). The window backgroundColor is theme-matched (see createWindow).
if (!getSettings().useHardwareAcceleration) {
app.disableHardwareAcceleration()
}
// Native window-occlusion tracking stays off in BOTH modes — it's a separate
// documented Windows blank/flicker cause, independent of GPU compositing.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
// Register aerofetch:// so a browser/another app can hand AeroFetch a link
// (?url=<encoded>) the way Android's share sheet hands Seal one. The NSIS
// 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
// 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.
// L143: an in-window, tray-INDEPENDENT way to quit at the moment the window "won't
// close" because a download is running — without it the only exit is the tray menu
// (and Task Manager if the tray ever failed to create). Guarded so hammering the X
// can't stack dialogs. Only used when the user did NOT opt into tray mode; a
// deliberate minimize-to-tray close stays a silent hide.
let quitPromptOpen = false
function promptQuitWhileDownloading(win: BrowserWindow): void {
if (quitPromptOpen) return
quitPromptOpen = true
dialog
.showMessageBox(win, {
type: 'question',
buttons: ['Keep downloading', 'Quit anyway'],
defaultId: 0,
cancelId: 0,
title: 'Download in progress',
message: 'A download is still in progress.',
detail:
'AeroFetch will keep downloading in the background — reopen it from the tray icon. Quit anyway to stop the download and exit now.'
})
.then(({ response }) => {
quitPromptOpen = false
if (response === 1) {
markQuitting() // lets the close handler through + runs the before-quit teardown
app.quit()
} else {
win.hide() // keep downloading in the background
}
})
.catch(() => {
quitPromptOpen = false
win.hide()
})
}
function getSystemThemeInfo(): SystemThemeInfo {
return {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
}
}
// Web permissions a download manager never needs. They're denied for the app
// window as defence-in-depth (audit T6): even if the renderer were compromised
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
// location, or reach USB/HID/serial/Bluetooth devices — none of which the IPC
// surface grants either. Clipboard (paste/copy) and everything else is left to
// the default so the app's own features keep working.
const DENIED_PERMISSIONS = new Set([
'media', // camera + microphone
'geolocation',
'midi',
'midiSysex',
'hid',
'serial',
'usb',
'bluetooth',
'speaker-selection',
'idle-detection'
])
function createWindow(): void {
// 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'),
@@ -106,6 +150,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.
@@ -113,6 +173,29 @@ function createWindow(): void {
else win.show()
})
// Closing the window hides to the tray (instead of quitting) when either the
// user opted into background mode, OR a download is in flight — quitting would
// 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) {
e.preventDefault()
// A download (not tray mode) is the only thing holding the app open: offer a
// tray-independent "Quit anyway" instead of silently hiding — otherwise a
// window that won't close looks like a bug, with no in-window way out (L143).
// A tray-mode close is the user's explicit choice, so that stays a silent hide.
if (downloadsRunning && !getSettings().minimizeToTray) {
promptQuitWhileDownloading(win)
} else {
win.hide()
}
}
})
win.on('closed', () => {
if (mainWindow === win) mainWindow = null
})
@@ -143,6 +226,15 @@ function createWindow(): void {
// away from it (defence in depth; HMR uses websockets, not navigation).
win.webContents.on('will-navigate', (e) => e.preventDefault())
// Deny the sensitive hardware/location web permissions the app never uses, so
// a compromised renderer can't escalate to capabilities the IPC surface
// doesn't grant. Both the async request and the sync check are covered. (audit T6)
const ses = win.webContents.session
ses.setPermissionRequestHandler((_wc, permission, callback) =>
callback(!DENIED_PERMISSIONS.has(permission))
)
ses.setPermissionCheckHandler((_wc, permission) => !DENIED_PERMISSIONS.has(permission))
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
@@ -150,139 +242,12 @@ function createWindow(): void {
}
}
function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
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.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))
}
// 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)
@@ -295,6 +260,15 @@ if (isPrimaryInstance) {
// link it carried and hand it to the window we already have.
app.on('second-instance', (_e, argv) => {
if (!mainWindow) return
// A scheduled `--sync` relaunch (Task Scheduler) hit the already-running instance:
// run the watched-source sync in this window instead of no-opping. Without this the
// daily auto-download never fires while AeroFetch is open — e.g. minimize-to-tray
// users. Deliberately does NOT focus the window, so the daily sync stays unobtrusive
// (mirrors the fresh-launch `--sync` showInactive path in createWindow).
if (isSyncLaunch(argv)) {
mainWindow.webContents.send(IpcChannels.runWatchedSync)
return
}
focusWindow(mainWindow)
const incomingUrl = extractIncomingUrl(argv)
if (incomingUrl) mainWindow.webContents.send(IpcChannels.externalUrl, incomingUrl)
@@ -303,23 +277,78 @@ 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()
// 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.
applyLaunchAtStartup(getSettings().launchAtStartup)
app.on('browser-window-created', (_, window) => {
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)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
// Taskbar right-click jump list: a quick "Open AeroFetch" task. Launching the
// exe again is caught by the single-instance lock, which focuses this window.
app.setUserTasks([
{
program: process.execPath,
arguments: '',
title: 'Open AeroFetch',
description: 'Open the AeroFetch window',
iconPath: process.execPath,
iconIndex: 0
}
])
// Seed the managed ffmpeg/ffprobe from the dev bundle when present, so a dev checkout
// (and any transitional build that still ships them) is ready without a download. In a
// shipped installer there's no seed, so this no-ops and the renderer's setup gate
// triggers the first-run download instead. Cheap + best-effort.
ensureManagedFfmpeg()
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
// once a day) self-update it to the chosen channel so a stale binary can't
// silently cause YouTube 403s. Best-effort and off the critical path — it
// never blocks startup, and status is pushed to the window if it's listening.
runStartupYtdlpAutoUpdate((status) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
}
}).catch((e) => logger.error('yt-dlp auto-update failed', e))
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
// 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.
// 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())
}
+79 -36
View File
@@ -1,6 +1,6 @@
/**
* Source indexing orchestration (Pinchflat-style media manager; see
* ROADMAP-PINCHFLAT.md). Walks a channel (its /playlists + /videos tabs) or a
* Source indexing orchestration (Pinchflat-style media manager). Walks a
* channel (its /playlists + /videos tabs) or a
* single playlist with `yt-dlp --flat-playlist`, merges the result into a deduped
* MediaItem list, and persists it as a Source. The download step (Phase G+) then
* pulls from that persisted list rather than the live queue holding it all.
@@ -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 {
@@ -23,7 +24,7 @@ import {
stableSourceId,
type RawEntry,
type NamedPlaylist
} from './indexerCore'
} from './core/indexerCore'
import { getSource, upsertSource, mergeMediaItems } from './sources'
import type { IndexProgress, IndexSourceResult, Source, SourceKind } from '@shared/ipc'
@@ -45,33 +46,59 @@ 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, signal?: AbortSignal): Promise<FlatInfo> {
const r = await execFileAsync(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
// `signal` lets a Cancel abort the in-flight probe child mid-walk (B2); Node
// kills the process and the call rejects, which the caller treats as canceled.
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS, signal }
)
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. */
function probeTab(url: string): Promise<FlatInfo | null> {
return probeFlat(url).catch(() => null)
function probeTab(url: string, signal?: AbortSignal): Promise<FlatInfo | null> {
return probeFlat(url, signal).catch(() => null)
}
// Active user-initiated index walks. `cancelIndexing()` aborts them so an
// `index:cancel` IPC can stop the walk and kill the in-flight probe child (B2).
// Background sync (sync.ts) calls indexSource without a signal, so it isn't
// user-cancelable — it's throttled and unattended, not a blocking UI action.
const activeIndexAborts = new Set<AbortController>()
/** Abort every in-flight user index/reindex walk (the add-source "Cancel" button). */
export function cancelIndexing(): void {
for (const c of activeIndexAborts) c.abort()
}
/**
* indexSource wrapped with a fresh AbortController registered for cancelIndexing().
* The IPC layer calls this; sync.ts calls the raw indexSource (not cancelable).
*/
export async function indexSourceCancelable(
onProgress: (p: IndexProgress) => void,
url: string
): Promise<IndexSourceResult> {
const controller = new AbortController()
activeIndexAborts.add(controller)
try {
return await indexSource(onProgress, url, controller.signal)
} finally {
activeIndexAborts.delete(controller)
}
}
/**
@@ -80,16 +107,23 @@ function probeTab(url: string): Promise<FlatInfo | null> {
* status line — it's best-effort and never affects the result.
*/
export async function indexSource(
onProgress: (p: IndexProgress) => void,
url: string,
onProgress: (p: IndexProgress) => void
signal?: AbortSignal
): Promise<IndexSourceResult> {
// Returned whenever a Cancel arrives mid-walk. It deliberately does NOT push a
// progress event: the renderer drives the cancel and resets its own indexing UI,
// so a late 'error' event can't re-surface as a failure (B2).
const canceled: IndexSourceResult = { ok: false, error: 'Indexing canceled.' }
let normalizedUrl: string
try {
assertHttpUrl(url)
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
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)
@@ -108,11 +142,13 @@ export async function indexSource(
// 1. Enumerate the channel's playlists, then each playlist's videos.
onProgress({ url, phase: 'playlists', message: 'Finding playlists…' })
const playlistTab = await probeTab(`${cls.base}/playlists`)
const playlistTab = await probeTab(`${cls.base}/playlists`, signal)
if (signal?.aborted) return canceled
const playlistEntries = playlistTab?.entries ?? []
const total = playlistEntries.length
let done = 0
for (const pe of playlistEntries) {
if (signal?.aborted) return canceled // stop before the next probe
done++
const purl = entryUrl(pe)
if (!purl) continue
@@ -123,7 +159,7 @@ export async function indexSource(
current: done,
total
})
const pdata = await probeTab(purl)
const pdata = await probeTab(purl, signal)
if (pdata?.entries?.length) {
playlists.push({ title: pe.title || 'Playlist', entries: pdata.entries })
}
@@ -131,7 +167,8 @@ export async function indexSource(
// 2. The full uploads feed — the catch-all for videos in no playlist.
onProgress({ url, phase: 'uploads', message: 'Indexing channel uploads…' })
const videoTab = await probeTab(`${cls.base}/videos`)
const videoTab = await probeTab(`${cls.base}/videos`, signal)
if (signal?.aborted) return canceled
uploads = videoTab?.entries ?? []
title =
@@ -146,12 +183,14 @@ export async function indexSource(
// resolve to a playlist when probed. A lone video has no entries → error.
kind = cls?.kind ?? 'playlist'
onProgress({ url, phase: 'uploads', message: 'Indexing playlist…' })
const data = await probeFlat(cls?.base ?? url)
const data = await probeFlat(cls?.base ?? normalizedUrl, signal)
if (signal?.aborted) return canceled
const entries = data.entries ?? []
if (entries.length === 0) {
return {
ok: false,
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
error:
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
}
}
title = data.title || 'Playlist'
@@ -167,6 +206,9 @@ export async function indexSource(
if (fresh.length === 0) {
return { ok: false, error: 'No downloadable videos found for this source.' }
}
// A cancel that landed after the final probe must not persist the source —
// otherwise a "canceled" add still pops into the library later (B2).
if (signal?.aborted) return canceled
// Incremental merge: preserve the downloaded state of anything already on
// disk, and report how many videos are new since the last index.
@@ -197,6 +239,7 @@ export async function indexSource(
})
return { ok: true, source, itemCount: items.length, newCount }
} catch (e) {
if (signal?.aborted) return canceled // the throw was our own abort — not an error
const msg = e instanceof Error ? e.message : String(e)
onProgress({ url, phase: 'error', message: msg })
return { ok: false, error: msg }
+348
View File
@@ -0,0 +1,348 @@
/**
* 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 MediaProfile,
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 {
getFfmpegSetupStatus,
downloadFfmpeg,
cancelFfmpegDownload,
locateFfmpegManually
} from './ffmpegSetup'
import { getAria2cPath } from './binaries'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { whenYtdlpUpdateSettled } from './ytdlpLock'
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 { listProfiles, saveProfile, removeProfile } from './profiles'
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,
setSourceProfile,
pruneMediaItems
} from './sources'
import { indexSourceCancelable, cancelIndexing } 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(e.sender, url)
)
ipcMain.handle(IpcChannels.appUpdateCancel, () => cancelAppUpdate())
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
// First-run ffmpeg/ffprobe acquisition (they're no longer bundled). Status gates the
// setup screen; download streams the pinned archive to e.sender; locate is the offline
// fallback (folder picker parented to the window, like chooseFolder).
ipcMain.handle(IpcChannels.ffmpegSetupStatus, () => getFfmpegSetupStatus())
ipcMain.handle(IpcChannels.ffmpegSetupDownload, (e) => downloadFfmpeg(e.sender))
ipcMain.handle(IpcChannels.ffmpegSetupCancel, () => cancelFfmpegDownload())
ipcMain.handle(IpcChannels.ffmpegSetupLocate, (e) =>
locateFfmpegManually(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
// Whether the optional aria2c external downloader shipped with this build, so the
// Settings toggle can disable itself instead of silently doing nothing (the flag
// is a no-op without the binary — see download.ts's existsSync gate).
ipcMain.handle(IpcChannels.aria2cAvailable, () => existsSync(getAria2cPath()))
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => {
// L139: hold the spawn until any in-progress yt-dlp self-update has finished
// swapping the exe, so a launch-time (auto-resumed) download waits it out instead
// of racing the rewrite. Resolves immediately when no update is running.
await whenYtdlpUpdateSettled()
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, async (e, id: string, args: string) => {
await whenYtdlpUpdateSettled() // L139: hold behind an in-progress exe rewrite
return 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; theme its
// background to the app's Light/Dark (UI23).
openCookieLoginWindow(
url,
BrowserWindow.fromWebContents(e.sender) ?? undefined,
PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
)
)
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.profilesList, () => listProfiles())
ipcMain.handle(IpcChannels.profilesSave, (_e, profile: MediaProfile) => saveProfile(profile))
ipcMain.handle(IpcChannels.profilesRemove, (_e, id: string) => removeProfile(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) ---
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, quality?: string) =>
setMediaItemDownloaded(id, filePath, quality)
)
// Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result.
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
indexSourceCancelable((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
}, url)
)
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
const src = getSource(id)
if (!src) return { ok: false, error: 'Source not found.' }
return indexSourceCancelable((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
}, src.url)
})
// Abort the in-flight index/reindex walk (kills the current probe child). (B2)
ipcMain.handle(IpcChannels.sourceIndexCancel, () => cancelIndexing())
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
setSourceWatched(id, watched)
)
ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) =>
setSourceProfile(id, profileId)
)
ipcMain.handle(IpcChannels.sourcePruneItems, (_e, sourceId: string, itemIds: string[]) =>
pruneMediaItems(sourceId, itemIds)
)
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
syncWatchedSources((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
)
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
// `time` (24h HH:MM) is validated inside setScheduledSync before touching schtasks (L51).
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean, time?: string) =>
setScheduledSync(enabled, time)
)
// 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(
PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
)
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 = ''
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Pure identification of a download's orphaned yt-dlp intermediate files (R4).
*
* When a running download is canceled, yt-dlp is tree-killed and leaves partial
* files behind in the output folder: the growing `.part`, fragment pieces
* (`.part-Frag*`) and the `.ytdl` fragment-state file for fragmented formats, plus
* any already-finished per-stream file (`<stem>.fNNN.<ext>` — e.g. the video half
* of a video+audio download whose audio was still in flight). A plain cancel never
* removes these, so they accumulate. download.ts captures the resolved FINAL output
* path from the before_dl `dest|` print; this module turns that path + a folder
* listing into the exact set to delete.
*
* Kept free of any electron/node-runtime chain (only `path.parse`) so it's
* unit-testable in isolation, the same posture as lib/formatters.ts (L37).
*
* SAFETY: it returns only unambiguous yt-dlp intermediates, matched on the output
* stem. It never returns a bare `<stem>.<ext>` completed file — so a pre-existing
* same-title download the user kept in the same folder is safe — nor any file
* belonging to a different download (different stem). This is the guard against the
* audit's "delete the wrong file if the destination capture is even slightly off".
*/
import { parse } from 'path'
export function orphanPartials(outputPath: string, entries: string[]): string[] {
// `name` is the basename without the final extension; a title containing dots is
// preserved (only the trailing `.<ext>` is stripped), so the stem stays exact.
const stem = parse(outputPath).name
if (!stem) return []
const stemDot = `${stem}.`
return entries.filter((entry) => {
if (!entry.startsWith(stemDot)) return false
return (
entry.endsWith('.part') || // <stem>….part — in-progress stream or merge
entry.includes('.part-Frag') || // fragment pieces (HLS/DASH)
entry.endsWith('.ytdl') || // fragment-download state file
/\.f\d+\.[^.]+$/i.test(entry) // finished per-stream file, e.g. <stem>.f399.mp4
)
})
}
+183
View File
@@ -0,0 +1,183 @@
import { net } from 'electron'
import { createWriteStream, type WriteStream } from 'fs'
import { unlink } from 'fs/promises'
import { createHash } from 'crypto'
/** Result of streaming a verified download to disk. */
export interface VerifiedDownloadResult {
ok: boolean
/** absolute path to the written file, when ok */
filePath?: string
error?: string
}
/** Live byte progress of a streaming download (main → renderer). */
export interface VerifiedDownloadProgress {
/** bytes received so far */
received: number
/** total bytes, when the server reports Content-Length */
total?: number
/** 0..1 fraction, when total is known */
fraction?: number
}
/** Context-specific message overrides; the defaults are generic and suit any caller. */
export interface VerifiedDownloadMessages {
/** shown when the streamed bytes don't match expectedSha */
checksumMismatch?: string
/** shown when the caller-armed cancel fires */
canceled?: string
}
export interface StreamVerifiedFileOptions {
url: string
filePath: string
/** published SHA-256 (lowercase hex) to verify against, or null to skip verification */
expectedSha: string | null
/** per-hop trust gate: a URL is fetched or followed only if this returns true for it */
isTrusted: (url: string) => boolean
onProgress: (p: VerifiedDownloadProgress) => void
onCancelReady: (cancel: () => void) => void
onSettled: (cancel: () => void) => void
/** stall budget: how long with no bytes received before abandoning (default 60s) */
idleTimeoutMs?: number
messages?: VerifiedDownloadMessages
}
/** Default stall (no-bytes) budget before a download is abandoned. */
const DEFAULT_IDLE_TIMEOUT_MS = 60_000
/**
* Stream one HTTPS resource into a file with full download discipline: per-hop redirect
* re-validation against a caller-supplied trust gate, streaming SHA-256 verification,
* idle-timeout, content-length truncation detection, write-stream backpressure, and a
* single teardown point that aborts the request and removes the partial file on every
* failure path.
*
* Extracted from the app updater (CL4) so the same audited loop backs both the installer
* download and the first-run ffmpeg fetch; the differences (which hosts are trusted, the
* checksum source, a couple of user-facing strings) are parameters, not forks. net.request
* (not fetch) is used so the host can be re-validated on EVERY redirect hop — undici's
* fetch hides the Location header under redirect:'manual', so it can't gate hops.
*
* The caller owns the cancel slot: `onCancelReady` hands it this download's cancel
* function once armed, and `onSettled` hands the same function back on completion so the
* caller can release the slot only if this download still owns it.
*/
export function streamVerifiedFile(
opts: StreamVerifiedFileOptions
): Promise<VerifiedDownloadResult> {
const { url, filePath, expectedSha, isTrusted, onProgress, onCancelReady, onSettled } = opts
const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
const canceledMsg = opts.messages?.canceled ?? 'Download canceled.'
const checksumMsg =
opts.messages?.checksumMismatch ??
'The download failed its checksum check — it may be corrupt or tampered with.'
return new Promise<VerifiedDownloadResult>((resolve) => {
let settled = false
let fileStream: WriteStream | null = null
let idle: NodeJS.Timeout | null = null
// Single teardown point. On failure it also aborts the request, so a write error
// (disk full, etc.) can't leave Electron pulling bytes into a dead stream. abort()
// is idempotent, so the call sites below don't repeat it.
const finish = (result: VerifiedDownloadResult): void => {
if (settled) return
settled = true
onSettled(requestCancel)
if (idle) clearTimeout(idle)
if (!result.ok) {
request.abort()
// Close the handle before unlinking (Windows won't delete an open file) and
// never leave a partial/aborted file lying around.
if (fileStream && !fileStream.destroyed) fileStream.destroy()
unlink(filePath).catch(() => {})
}
resolve(result)
}
// Expose this download's abort so the caller's Cancel routes through the same
// teardown — request.abort() + partial cleanup.
const requestCancel = (): void => finish({ ok: false, error: canceledMsg })
onCancelReady(requestCancel)
// 'manual' means a hop only proceeds if we call followRedirect().
const request = net.request({ url, redirect: 'manual' })
const armIdle = (): void => {
if (idle) clearTimeout(idle)
idle = setTimeout(() => {
finish({ ok: false, error: 'Download stalled — please try again.' })
}, idleTimeoutMs)
}
request.on('redirect', (_status, _method, redirectUrl) => {
if (!isTrusted(redirectUrl)) {
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
return
}
const lenHeader = response.headers['content-length']
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
const stream = createWriteStream(filePath)
fileStream = stream
// Hash the bytes as they stream by, so verification needs no second pass.
const hash = createHash('sha256')
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume() exist at
// runtime but aren't in its type, so feature-detect them to apply backpressure —
// without it a large download can buffer in memory.
const flow = response as unknown as { pause?(): void; resume?(): void }
let received = 0
armIdle()
response.on('data', (chunk: Buffer) => {
armIdle()
received += chunk.length
hash.update(chunk)
if (!stream.write(chunk) && flow.pause && flow.resume) {
flow.pause()
stream.once('drain', () => flow.resume?.())
}
onProgress({ received, total, fraction: total ? received / total : undefined })
})
response.on('end', () => {
// Body fully received — "stalled" is no longer meaningful past this point.
if (idle) clearTimeout(idle)
stream.end(() => {
// A truncated download (connection dropped mid-stream) must never be used.
if (total !== undefined && received !== total) {
finish({ ok: false, error: 'The download was incomplete — please try again.' })
return
}
// Verify the bytes we wrote match the published SHA-256.
if (expectedSha && hash.digest('hex') !== expectedSha) {
finish({ ok: false, error: checksumMsg })
return
}
finish({ ok: true, filePath })
})
})
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
// promise can't hang.
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
request.end()
})
}
+58
View File
@@ -12,3 +12,61 @@ export function cleanError(stderr: string): string {
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
// Lines that carry no diagnostic value as post-processing context: per-tick
// download progress and the "Deleting original file" cleanup bookkeeping yt-dlp
// prints between merge steps.
const CONTEXT_NOISE = /^\[download\]|^Deleting original file/i
// A single context line, capped so a long output path can't bloat the message.
const CONTEXT_LINE_MAX = 200
/**
* Richer error for the DOWNLOAD failure path (probe/indexer/updater keep the
* concise cleanError line).
*
* yt-dlp collapses an ffmpeg post-processing failure to a bare
* "ERROR: Postprocessing: Conversion failed!" that names neither the step that
* died nor the reason — and without `--verbose` the underlying ffmpeg detail is
* never emitted at all. What yt-dlp DOES print is a tagged progress line per
* post-processor ([Merger], [ModifyChapters], [ExtractAudio], …; its phase tags
* like [download]/[info] are lowercase). So for that generic wrapper we append
* the last couple of non-noise trailing lines, which name the failing step (e.g.
* the SponsorBlock re-encode) and surface any ffmpeg diagnostic when one is
* present. Self-explanatory errors (a private video, a 403) are returned
* unchanged.
*/
export function describeDownloadError(stderr: string): string {
const headline = cleanError(stderr)
if (!headline || !/conversion failed|postprocessing/i.test(headline)) return headline
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const context: string[] = []
for (const line of [...lines].reverse()) {
if (context.length >= 2) break
if (CONTEXT_NOISE.test(line) || /^error/i.test(line)) continue
// Skip anything already conveyed by the headline (avoids echoing it back).
if (headline.includes(line) || line.includes(headline)) continue
const capped = line.length > CONTEXT_LINE_MAX ? `${line.slice(0, CONTEXT_LINE_MAX - 1)}` : line
if (!context.includes(capped)) context.unshift(capped)
}
return context.length > 0 ? `${headline}${context.join(' · ')}` : headline
}
/**
* Full non-noise stderr, for the persisted error log's "Show details" expander
* (Diagnostics card) — everything describeDownloadError condenses to one line,
* kept in full (progress ticks and delete-bookkeeping still stripped) so a bug
* report carries the real yt-dlp/ffmpeg transcript instead of a paraphrase.
* Returns undefined when there's nothing beyond a single line — the headline
* the caller already shows — so a plain, self-explanatory error doesn't grow a
* details toggle with nothing new in it.
*/
export function stderrDetail(stderr: string): string | undefined {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !CONTEXT_NOISE.test(l))
return lines.length > 1 ? lines.join('\n') : undefined
}
+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 */
}
}
}
+154
View File
@@ -0,0 +1,154 @@
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(
/** app-theme window background (UI23) — supplied by the IPC layer so this
* window's chrome matches the app's Light/Dark instead of flashing white. */
backgroundColor?: string
): 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',
// Match the app's resolved theme (UI23), like the cookie sign-in window.
backgroundColor,
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 */
})
})
}
+37 -44
View File
@@ -1,10 +1,11 @@
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'
import { entryUrl, fmtDuration, type RawEntry } from './core/indexerCore'
import {
BEST_FORMAT_ID,
type ProbeResult,
@@ -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,51 +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 {
assertHttpUrl(url)
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', '--', url],
{ 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 type { MediaProfile, MediaKind, DownloadOptions } from '@shared/ipc'
import { isProfileLike } from './core/validation'
import { createJsonStore } from './jsonStore'
import { PROFILES_MAX } from './constants'
// Named download presets a Library Source can point at (PINCHFLAT Media
// Profiles). Plain JSON in userData, same shape/pattern as templates.ts —
// atomic writes / corruption backup / caching from the shared jsonStore (R1R3).
// A persisted row must at least be an object carrying an id (isProfileLike);
// everything else is coerced by sanitize(), so a hand-edited profiles.json can't
// inject malformed entries. (audit S5)
const store = createJsonStore('profiles.json', isProfileLike, PROFILES_MAX)
const KINDS: readonly MediaKind[] = ['video', 'audio']
/** Normalise a persisted/incoming profile so every field is well-formed. */
function sanitize(p: MediaProfile): MediaProfile {
const out: MediaProfile = {
id: String(p.id),
name: (p.name ?? '').trim() || 'Untitled profile'
}
if (p.kind && KINDS.includes(p.kind)) out.kind = p.kind
if (typeof p.quality === 'string' && p.quality.trim()) out.quality = p.quality.trim()
// options is stored whole when present; the download path re-sanitizes it
// through the settings schema before use, so a weak object check is enough here.
if (p.options && typeof p.options === 'object') out.options = p.options as DownloadOptions
if (typeof p.outputTemplate === 'string' && p.outputTemplate.trim()) {
out.outputTemplate = p.outputTemplate.trim()
}
if (typeof p.extraArgs === 'string' && p.extraArgs.trim()) out.extraArgs = p.extraArgs.trim()
return out
}
export function listProfiles(): MediaProfile[] {
return store.read().map(sanitize)
}
/** Add a new profile, or update an existing one (matched by id). */
export function saveProfile(profile: MediaProfile): MediaProfile[] {
const clean = sanitize(profile)
return store.write([clean, ...listProfiles().filter((p) => p.id !== clean.id)])
}
export function removeProfile(id: string): MediaProfile[] {
return store.write(listProfiles().filter((p) => p.id !== id))
}
+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 ''
}
+25 -10
View File
@@ -1,6 +1,6 @@
/**
* Windows Task Scheduler integration for the daily watched-source sync
* (ROADMAP-PINCHFLAT.md Phase J). Registers a task that launches AeroFetch with
* Windows Task Scheduler integration for the daily watched-source sync.
* Registers a task that launches AeroFetch with
* `--sync` once a day; the app then runs its startup sync of watched sources.
*
* NOTE: this is OS-level wiring and cannot be exercised in the Vite UI preview or
@@ -10,7 +10,8 @@
*/
import { execFile } from 'child_process'
import type { ScheduledSyncStatus } from '@shared/ipc'
import { getSystem32Path } from './binaries'
import { isValidSyncTime, DEFAULT_SETTINGS, type ScheduledSyncStatus } from '@shared/ipc'
const TASK_NAME = 'AeroFetchDailySync'
/** The argv flag the scheduled task passes so startup knows it's a sync launch. */
@@ -18,10 +19,17 @@ export const SYNC_FLAG = '--sync'
function schtasks(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
execFile('schtasks', args, { windowsHide: true }, (err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
})
// Resolve schtasks from System32 by absolute path, not the bare name, so a
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
execFile(
getSystem32Path('schtasks.exe'),
args,
{ windowsHide: true },
(err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
}
)
})
}
@@ -38,10 +46,17 @@ export async function getScheduledSync(): Promise<ScheduledSyncStatus> {
/**
* Register or remove the daily-sync scheduled task. Creating runs AeroFetch with
* `--sync` every day at 09:00 (overwriting any prior task of the same name).
* `--sync` every day at the given 24h 'HH:MM' time (overwriting any prior task of
* the same name — which is also how a time change re-registers, L51). The time is
* renderer-supplied over IPC, so it's validated here before reaching the schtasks
* argv; anything malformed falls back to the default rather than failing the toggle.
*/
export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncStatus> {
export async function setScheduledSync(
enabled: boolean,
time?: string
): Promise<ScheduledSyncStatus> {
if (enabled) {
const startTime = isValidSyncTime(time) ? time : DEFAULT_SETTINGS.syncTime
const tr = `"${process.execPath}" ${SYNC_FLAG}`
const r = await schtasks([
'/Create',
@@ -49,7 +64,7 @@ export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncS
'/SC',
'DAILY',
'/ST',
'09:00',
startTime,
'/TN',
TASK_NAME,
'/TR',
+164 -149
View File
@@ -1,109 +1,149 @@
import { app } from 'electron'
import { join } from 'path'
import { app, safeStorage } from 'electron'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS,
type Settings,
type DownloadOptions,
type SponsorBlockCategory
} from '@shared/ipc'
import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
import { parseSettingsField } from './settingsSchema'
import { sanitizeOptions } from './settingsOptions'
import { logger } from './logger'
import { ACCENT_COLORS, DEFAULT_SETTINGS, type Settings, type DownloadOptions } from '@shared/ipc'
const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read
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',
restrictFilenames: false,
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: false
}
// 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
/** Fixed path for the --download-archive file; not user-configurable. */
export function getDownloadArchivePath(): string {
return join(app.getPath('userData'), 'download-archive.txt')
}
// 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 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!
: d.videoContainer,
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
? o.preferredVideoCodec!
: d.preferredVideoCodec,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
? o.subtitleLanguages.trim()
: d.subtitleLanguages,
autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles),
sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock),
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
/**
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
* startup and whenever the toggle changes. On Windows this writes/removes a
* per-user registry Run entry — no admin needed, matching the app's no-elevation
* stance. Best-effort: a failure here just means the toggle didn't take effect.
*/
export function applyLaunchAtStartup(enabled: boolean): void {
try {
app.setLoginItemSettings({ openAtLogin: enabled })
} catch {
/* non-fatal — e.g. unsupported platform */
}
}
// Constructed lazily — electron-store needs app paths, which exist only after
// the app is ready (all callers run post-ready).
// sanitizeOptions moved to settingsOptions.ts (CC9) so the field schemas can
// reuse it without an import cycle; re-imported above for the legacy-file
// migration below.
// Constructed lazily — electron-store needs app paths. (`userData` resolves
// pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.)
let store: Store<Settings> | null = null
function getStore(): Store<Settings> {
if (!store) store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
if (!store) {
store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
// CC1 key-rename migration: move any legacy key still on disk to its new
// name, once. `has()` reports defaults as present, so probe the raw store
// object instead; idempotent because the old key is deleted afterwards.
const raw = store.store as unknown as Record<string, unknown>
const legacy = RENAMED_SETTINGS_KEYS.filter(([oldKey]) => oldKey in raw)
if (legacy.length > 0) {
const untyped = store as unknown as {
set(k: string, v: unknown): void
delete(k: string): void
}
for (const [oldKey, newKey] of legacy) {
untyped.set(newKey, raw[oldKey])
untyped.delete(oldKey)
}
}
}
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 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 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'] 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
// was unavailable) and migrate it on the next write.
const ENC_PREFIX = 'enc:v1:'
/** Encrypt a secret for storage. Falls back to plaintext where safeStorage is unavailable. */
function encryptSecret(plain: string): string {
if (!plain) return ''
try {
if (safeStorage.isEncryptionAvailable()) {
return ENC_PREFIX + safeStorage.encryptString(plain).toString('base64')
}
} 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
}
/** Decrypt a stored secret. Legacy plaintext is returned as-is; an undecryptable blob → ''. */
function decryptSecret(stored: string): string {
if (!stored.startsWith(ENC_PREFIX)) return stored
try {
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
} catch {
// Different user/machine or a corrupt blob — drop it rather than surface
// ciphertext into the UI or onto a yt-dlp command line.
return ''
}
}
/** A copy of settings with the credential fields decrypted for in-process use. */
function withDecryptedSecrets(raw: Settings): Settings {
const out = { ...raw }
for (const key of SECRET_KEYS) out[key] = decryptSecret(raw[key] ?? '')
return out
}
/**
* One-time (per launch) migration: re-store any credential still held as legacy
* plaintext as ciphertext. Lets settings files written before at-rest encryption
* get protected without a write on the hot getSettings() path. No-op when there's
* nothing to migrate or safeStorage is unavailable.
*/
export function migrateSecretsAtRest(): void {
let available = false
try {
available = safeStorage.isEncryptionAvailable()
} catch {
return
}
if (!available) return
const s = getStore()
for (const key of SECRET_KEYS) {
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
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
if (!cur.outputDir) {
// Fill in the real Downloads path the first time, and persist it once.
s.set('outputDir', app.getPath('downloads'))
}
// videoFolder/audioFolder are intentionally left blank by default — an empty value
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
// Only an explicit user choice (Settings → folders) overrides that.
// Migrate settings files that predate downloadOptions (or hold a partial one),
// but only persist when sanitizing actually altered the stored value.
const sanitized = sanitizeOptions(cur.downloadOptions)
@@ -115,7 +155,15 @@ export function getSettings(): Settings {
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
s.set('accentColor', DEFAULTS.accentColor)
}
return s.store
// 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).
cachedSettings = withDecryptedSecrets(s.store)
return cachedSettings
}
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
@@ -141,69 +189,36 @@ 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
switch (key) {
case 'theme':
if (value === 'light' || value === 'dark' || value === 'system') s.set('theme', value)
break
case 'accentColor':
if ((ACCENT_COLORS as readonly string[]).includes(value as string)) {
s.set('accentColor', value as Settings['accentColor'])
}
break
case 'defaultKind':
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
break
case 'maxConcurrent': {
const n = Number(value)
if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n))))
break
}
case 'clipboardWatch':
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value)
break
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break
case 'cookieSource':
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
break
case 'cookiesBrowser':
if ((COOKIE_BROWSERS as readonly string[]).includes(value as string)) {
s.set(key, value as Settings['cookiesBrowser'])
}
break
case 'downloadOptions':
// Always store a fully-validated object; the renderer sends the whole
// group (it merges field changes locally before calling setSettings).
s.set('downloadOptions', sanitizeOptions(value))
break
case 'outputDir':
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
break
case 'filenameTemplate':
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
s.set('filenameTemplate', value.trim())
}
break
case 'defaultVideoQuality':
case 'defaultAudioQuality':
// proxy may carry plaintext credentials (user:pass@host); they are stored
// and exported by exportBackup as-is — documented, not masked.
case 'proxy':
case 'rateLimit':
if (typeof value === 'string') s.set(key, value)
break
// One schema layer validates AND coerces every field (CC9) — the parsed
// output is exactly what gets stored; a failed parse skips the key so the
// stored value stays. Side effects that don't belong in a pure schema stay
// here: credential fields are encrypted at rest (see encryptSecret; they're
// stripped from backup exports via SECRET_KEYS), and launchAtStartup syncs
// the OS run-at-sign-in entry.
const parsed = parseSettingsField(key, value)
if (!parsed.ok) continue
if (SECRET_KEYS.includes(key as (typeof SECRET_KEYS)[number])) {
s.set(key, encryptSecret(parsed.value as string) as Settings[typeof key])
} else {
s.set(key, parsed.value)
}
if (key === 'launchAtStartup') applyLaunchAtStartup(parsed.value as boolean)
}
return getSettings()
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Settings key renames (CC1): boolean keys follow the is/has/should/<verb>
* convention and folder keys say "folder" (matching the UI) instead of "dir".
* The rename is a breaking on-disk change for `settings.json`, so it ships with
* this migration, applied in two places:
*
* - the electron-store shim in settings.ts, which moves any old key found on
* disk to its new name once (idempotent — the old key is deleted after);
* - backup import (backup.ts), so a backup exported by an older AeroFetch
* still restores cleanly.
*
* Pure and unit-tested (test/settingsMigration.test.ts). New settings keys must
* follow the convention up front — this list should only ever grow when a key
* is deliberately renamed.
*/
export const RENAMED_SETTINGS_KEYS: ReadonlyArray<readonly [string, string]> = [
['customCommandEnabled', 'enableCustomCommands'],
['downloadArchive', 'useDownloadArchive'],
['clipboardWatch', 'watchClipboard'],
['sidebarCollapsed', 'isSidebarCollapsed'],
['hardwareAcceleration', 'useHardwareAcceleration'],
['videoDir', 'videoFolder'],
['audioDir', 'audioFolder']
]
/**
* Return a copy of `obj` with every legacy key moved to its new name. A value
* already present under the NEW name wins (the old key is simply dropped), so
* a half-migrated or hand-merged file can't regress a newer value.
*/
export function migrateSettingsKeys(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...obj }
for (const [oldKey, newKey] of RENAMED_SETTINGS_KEYS) {
if (oldKey in out) {
if (!(newKey in out)) out[newKey] = out[oldKey]
delete out[oldKey]
}
}
return out
}
+62
View File
@@ -0,0 +1,62 @@
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
type SponsorBlockCategory,
type AudioFormat,
type VideoContainer,
type VideoCodecPref
} from '@shared/ipc'
// 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. Lives
// in its own module (not settings.ts) so the settings field schemas (CC9) can
// reuse it without an import cycle.
export 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 cats = Array.isArray(o.sponsorBlockCategories)
? (o.sponsorBlockCategories.filter((c) =>
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
) as SponsorBlockCategory[])
: d.sponsorBlockCategories
return {
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 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),
subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
? o.subtitleLanguages.trim()
: d.subtitleLanguages,
autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles),
sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock),
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
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),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* The one validate-AND-coerce layer for Settings writes (CC9). Every key that
* setSettings accepts is described here as a zod schema whose output is exactly
* what gets persisted — replacing the bespoke per-key switch. The schemas reuse
* validation.ts's domain predicates (`isValidSyncTime`, `isSafeOutputDir`,
* `isSafeFilenameTemplate`) as refinements, so the rules live in one place.
*
* Semantics are a faithful port of the old switch (unit-tested in
* test/settingsSchema.test.ts): invalid values fail parse and are SKIPPED by
* applySettings (the stored value stays), numbers coerce + clamp, strings trim
* where they trimmed before. Side effects (secret encryption, launch-at-startup
* registration) stay in settings.ts — this layer is pure.
*
* The jsonStore row guards (`isValid*` in validation.ts) intentionally stay
* type-guard predicates: they filter untrusted disk rows in a hot read path and
* need no coercion.
*/
import { z } from 'zod'
import {
ACCENT_COLORS,
COOKIE_BROWSERS,
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
YTDLP_UPDATE_CHANNELS,
isValidSyncTime,
type Settings
} from '@shared/ipc'
import { isSafeOutputDir, isSafeFilenameTemplate } from './core/validation'
import { sanitizeOptions } from './settingsOptions'
/** Coerce to a finite number, round, and clamp into [min, max] (maxConcurrent, aria2c). */
const clampedInt = (min: number, max: number) =>
z.coerce
.number()
.refine((n) => Number.isFinite(n))
.transform((n) => Math.min(max, Math.max(min, Math.round(n))))
/** Trimmed string that must satisfy the given predicate after trimming. */
const trimmedWhere = (ok: (v: string) => boolean) =>
z
.string()
.transform((v) => v.trim())
.refine(ok)
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
const RATE_LIMIT_RE = /^(\d+\.?\d*[KMGkmg]?B?)?$/
const bool = z.boolean()
/**
* One schema per Settings key; `applySettings` looks the key up, `safeParse`s
* the incoming value, and stores the parsed output — or skips the key entirely
* on failure. Typed as ZodType<unknown> per-field because outputs differ.
*/
export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType<Settings[K], unknown> } = {
theme: z.enum(['light', 'dark', 'system']),
accentColor: z.enum(ACCENT_COLORS),
defaultKind: z.enum(['video', 'audio']),
maxConcurrent: clampedInt(1, 5),
// aria2c caps -x/-s at 16; clamp like maxConcurrent rather than reject (L64).
aria2cConnections: clampedInt(1, 16),
// Reaches `schtasks /ST` when the daily sync is (re)registered (L51).
syncTime: z.string().refine(isValidSyncTime),
watchClipboard: bool,
useAria2c: bool,
restrictFilenames: bool,
useDownloadArchive: bool,
autoUpdateYtdlp: bool,
enableCustomCommands: bool,
notifyOnComplete: bool,
autoDownloadNew: bool,
hasCompletedOnboarding: bool,
minimizeToTray: bool,
isSidebarCollapsed: bool,
useHardwareAcceleration: bool,
launchAtStartup: bool,
// Same allowlist that guards the `--update-to` flag (audit F1).
ytdlpChannel: z.enum(YTDLP_UPDATE_CHANNELS),
// Set by the main-process auto-updater; a bogus value at worst skips one check.
ytdlpLastUpdateCheck: z.coerce.number().refine((n) => Number.isFinite(n) && n >= 0),
defaultTemplateId: z.union([z.null(), z.string()]),
cookieSource: z.enum(['none', 'browser', 'login']),
cookiesBrowser: z.enum(COOKIE_BROWSERS),
// Always store a fully-validated object; the renderer sends the whole group.
downloadOptions: z.unknown().transform((v) => sanitizeOptions(v)),
// An empty string is allowed — it clears the override and restores the default.
videoFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
audioFolder: trimmedWhere((v) => v === '' || isSafeOutputDir(v)),
filenameTemplate: trimmedWhere(isSafeFilenameTemplate),
// Collection folder layout (PINCHFLAT): empty = built-in default; otherwise a
// safe relative yt-dlp template (isSafeFilenameTemplate allows '/'-separated
// path segments and rejects absolute paths + '..' traversal).
collectionOutputTemplate: trimmedWhere((v) => v === '' || isSafeFilenameTemplate(v)),
defaultVideoQuality: z.enum(VIDEO_QUALITY_OPTIONS),
defaultAudioQuality: z.enum(AUDIO_QUALITY_OPTIONS),
rateLimit: trimmedWhere((v) => RATE_LIMIT_RE.test(v)),
// Throttle seconds (0300, 0 = off) to dodge YouTube's rate-limit/403 wall.
sleepRequests: clampedInt(0, 300),
sleepInterval: clampedInt(0, 300),
maxSleepInterval: clampedInt(0, 300),
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
youtubePlayerClient: z.string().transform((v) => v.trim()),
// Credential-bearing fields — settings.ts encrypts these AFTER parsing.
proxy: z.string(),
youtubePoToken: z.string()
}
/**
* Parse one incoming settings value. Returns `{ ok: true, value }` with the
* coerced/clamped/trimmed value to store, or `{ ok: false }` when the write
* must be skipped (unknown key or failed validation).
*/
export function parseSettingsField<K extends keyof Settings>(
key: K,
value: unknown
): { ok: true; value: Settings[K] } | { ok: false } {
const schema = SETTINGS_FIELD_SCHEMAS[key] as z.ZodType<Settings[K], unknown> | undefined
if (!schema) return { ok: false }
const parsed = schema.safeParse(value)
return parsed.success ? { ok: true, value: parsed.data } : { ok: false }
}
+93 -60
View File
@@ -1,59 +1,40 @@
/**
* Persistence for the media-manager index (Pinchflat-style; see
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
* better-sqlite3 — revisit if a user indexes many large channels, see the
* Phase H risk note in the roadmap):
* Persistence for the media-manager index (Pinchflat-style). 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):
*
* 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 { existsSync, unlinkSync } from 'fs'
import type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore'
import { isValidSource, isValidMediaItem } from './core/validation'
import { mergeItemsPreservingState } from './core/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,40 @@ 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)))
}
/**
* Point a source at a MediaProfile, or clear it (profileId === null). Returns
* all sources. A cleared profile drops the field entirely so the record stays
* clean rather than carrying an explicit undefined.
*/
export function setSourceProfile(id: string, profileId: string | null): Source[] {
return sourcesStore.write(
listSources().map((s) => {
if (s.id !== id) return s
const { profileId: _drop, ...rest } = s
return profileId ? { ...rest, profileId } : rest
})
)
}
/** 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 +85,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])
}
/**
@@ -123,13 +115,54 @@ export function mergeMediaItems(
* updated list for that item's source (or [] if the id is unknown). Used by the
* library view once a queued item completes (Phase H/I).
*/
export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem[] {
export function setMediaItemDownloaded(
id: string,
filePath?: string,
quality?: string
): MediaItem[] {
const all = listAllItems()
const target = all.find((m) => m.id === id)
if (!target) return []
const updated = all.map((m) =>
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
m.id === id
? { ...m, downloaded: true, downloadedAt: Date.now(), filePath, downloadedQuality: quality }
: m
)
writeJson(itemsFile(), updated)
itemsStore.write(updated)
return updated.filter((m) => m.sourceId === target.sourceId)
}
/**
* Prune (delete the files of) the given media items for a source, then clear
* their downloaded state so the Library shows them as pending again. The
* download-archive guard (Settings.useDownloadArchive) keeps a pruned video from
* silently re-downloading on the next "Download new only". Returns the updated
* item list for the source and how many files were actually deleted.
*
* File deletion is the caller's explicit, dry-run-confirmed action (the source
* detail's Prune button) — never automatic.
*/
export function pruneMediaItems(
sourceId: string,
itemIds: string[]
): { items: MediaItem[]; deleted: number } {
const remove = new Set(itemIds)
const all = listAllItems()
let deleted = 0
const updated = all.map((m) => {
if (!remove.has(m.id)) return m
if (m.filePath && existsSync(m.filePath)) {
try {
unlinkSync(m.filePath)
deleted++
} catch {
/* best-effort — a locked/already-gone file just stays counted as not-deleted */
}
}
// Clear the on-disk state; the download archive prevents a silent re-download.
const { filePath: _f, downloadedAt: _a, downloadedQuality: _q, ...rest } = m
return { ...rest, downloaded: false }
})
itemsStore.write(updated)
return { items: updated.filter((m) => m.sourceId === sourceId), deleted }
}
+9 -4
View File
@@ -1,5 +1,5 @@
/**
* Watched-source sync (Pinchflat-style; ROADMAP-PINCHFLAT.md Phase J). Re-indexes
* Watched-source sync (Pinchflat-style). Re-indexes
* every watched Source and reports the videos that are new since its last index.
* A YouTube RSS feed is used as a cheap pre-check so a source with no new uploads
* is skipped before the (more expensive) full `yt-dlp` re-index.
@@ -7,12 +7,17 @@
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
import { parseRssVideoIds } from './indexerCore'
import { parseRssVideoIds, isYouTubeFeedUrl } from './core/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. */
async function fetchFeedIds(feedUrl: string): Promise<string[]> {
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
// Only ever fetch a genuine YouTube feed — refuse an arbitrary host that a
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
// caught by the caller, which then falls back to a full yt-dlp re-index.
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
if (!res.ok) throw new Error(`feed responded ${res.status}`)
return parseRssVideoIds(await res.text())
}
@@ -36,7 +41,7 @@ export async function syncWatchedSources(
if (recent && recent.length > 0 && recent.every((id) => known.has(id))) continue
}
const before = new Set(listMediaItems(src.id).map((m) => m.videoId))
const res = await indexSource(src.url, onProgress)
const res = await indexSource(onProgress, src.url)
if (res.ok) {
for (const it of listMediaItems(src.id)) {
if (!before.has(it.videoId)) newItems.push(it)
+17 -38
View File
@@ -1,65 +1,44 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { CommandTemplate } from '@shared/ipc'
import { isTemplateLike } from './validation'
// Plain JSON in userData, same shape as history.ts.
const MAX_TEMPLATES = 100
function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
import { isTemplateLike } from './core/validation'
import { createJsonStore } from './jsonStore'
import { TEMPLATES_MAX } from './constants'
// 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 {
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
return {
id: String(t.id),
name: (t.name ?? '').trim() || 'Untitled command',
args: (t.args ?? '').trim()
args: (t.args ?? '').trim(),
// Only persist a urlPattern when one was provided, keeping older files clean.
...(urlPattern ? { urlPattern } : {})
}
}
/** 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))
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed
* arguments and streams stdout/stderr back to the renderer line-by-line.
*
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
* and the whole feature is gated on Settings.enableCustomCommands — the same consent
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
*/
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
import { parseExtraArgs } from './core/buildArgs'
import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>()
function send(wc: WebContents, ev: TerminalEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev)
}
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
if (!getSettings().enableCustomCommands) {
return {
ok: false,
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
}
}
ensureManagedYtdlp()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
}
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
// L139: same exe-rewrite interlock as downloads — don't launch yt-dlp while a
// self-update is replacing the binary.
if (isYtdlpUpdating()) {
return { ok: false, error: 'yt-dlp is updating — try again in a moment.' }
}
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
let child: ChildProcess
try {
child = spawn(ytdlp, args, { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
active.set(id, child)
acquireSpawn() // L139: live yt-dlp process — the updater must defer to it
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
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) => lineBufs.stdout.push(c.toString()))
child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
let settled = false
child.on('error', (err) => {
if (settled) return
settled = true
active.delete(id)
releaseSpawn()
send(wc, { type: 'error', id, error: err.message })
})
child.on('close', (code) => {
if (settled) return
settled = true
active.delete(id)
releaseSpawn()
// Flush any trailing partial lines that never hit a newline.
lineBufs.stdout.flush()
lineBufs.stderr.flush()
send(wc, { type: 'done', id, code })
})
return { ok: true }
}
export function cancelTerminal(id: string): void {
const child = active.get(id)
if (!child) return
const pid = child.pid
if (pid != null) {
// Tree-kill via System32 taskkill by absolute path (same hardening as download.ts).
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
child.kill()
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* System tray + minimize-to-tray (Phase O). When Settings.minimizeToTray is on,
* closing the window hides it to the tray instead of quitting; the tray's "Quit"
* really exits. The tray gives the app a background presence (a natural home for
* the watched-source sync) and a quick show/quit menu.
*/
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
import { getAppIconPath } from './binaries'
import { FALLBACK_TRAY_PNGS } from './trayFallbackIcons'
let tray: Tray | null = null
// True once the user has chosen to really quit (tray menu, or app.quit from the
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
let quitting = false
export function isQuitting(): boolean {
return quitting
}
export function markQuitting(): void {
quitting = true
}
function show(getWindow: () => BrowserWindow | null): void {
const win = getWindow()
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
export function createTray(getWindow: () => BrowserWindow | null): void {
if (tray) return
// Prefer the real app icon; fall back to the embedded multi-resolution glyph
// when no icon.ico ships, so minimize-to-tray always has a tray to restore
// from — and stays crisp at 150%/200% DPI (W12). Without any icon the tray is
// skipped (an empty image makes an invisible/unclickable Windows tray entry),
// which would strand a minimized-to-tray window with no way back.
let icon = nativeImage.createFromPath(getAppIconPath())
if (icon.isEmpty()) {
icon = nativeImage.createEmpty()
for (const [scaleFactor, b64] of FALLBACK_TRAY_PNGS) {
// dataURL (not `buffer`) so the PNG is decoded — `buffer` is raw bitmap data.
icon.addRepresentation({ scaleFactor, dataURL: `data:image/png;base64,${b64}` })
}
}
if (icon.isEmpty()) return
tray = new Tray(icon)
tray.setToolTip('AeroFetch')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show AeroFetch', click: () => show(getWindow) },
{ type: 'separator' },
{
label: 'Quit AeroFetch',
click: () => {
quitting = true
app.quit()
}
}
])
)
tray.on('click', () => show(getWindow))
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Multi-resolution fallback tray glyphs (W12), rendered from build/icon.svg at
* 16/24/32/48 px (scale factors 1/1.5/2/3) so the fallback tray icon stays
* crisp on high-DPI displays even when the bundled icon.ico is missing.
* Regenerate with ImageMagick:
* magick -background none build/icon.svg -resize NxN -strip out.png
* (base64-embedded so the fallback needs no asset-bundling step).
*/
export const FALLBACK_TRAY_PNGS: ReadonlyArray<readonly [number, string]> = [
// 16×16 — scaleFactor 1
[
1,
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAFEElEQVRIx52Ua1CVVRSGn/WdczhwAAnRgCPgDfGGihCZiqXj' +
'bdS0EqyZSsUUMsy71ahlZU5W2miWOWkmYc14BxW18VaD5oji/QoqircwQUsEzne+c77dDxAx6o/vnz2z9157ve+711rS3Z29' +
'aavXJ0CepL90Su8hrbQICZ7xK5N4i/Sow/hjw1+68LioxKBSHeNrlrL86mvqmlmsyhZOwCBfFSyfK4kt183YlPt2qnSUGOn8' +
'6UK6kSQ9AkO4zz0qAAUoTDRA0LhSs++dp66Zf9UmUWB5XyK1YKAFjQgEzNo4ATQ0AmhEAJDPPnWgwq2Oq7Pq7KyREtf9Z7+1' +
'va8IT5NE7+Ym1VRRRRUgCI46JVdrEst7rJUseGZi+LbQrwALGgIHl/wx+NYkUPN5WY0ComqJPIRCUYUfDhw4OMhv5F25Lh3S' +
'fvg8a4u5H8HElJ61mhtAmXzBIghebV9lHwqZsYPCBrgBK4JA6okdpTttcPd1PVXfCqLxLlP/81Oklo4gapfVHak31WOkJyaq' +
'zjLQ6q678eIFtUXdxgNmX4tDewl8mmrlWs6DDwAzxJNkvAjufFe1ng0yTFYRDfhgwfKIDwAmGoJIf6vbrf+pl9Qda3Uc71BN' +
'NahilUYGqBDS1Megeyx5Wh6ovapAHX34rt7BtU9vDHqInuYaA3KEeTIQpJWs4FugMX741ZGoE2h1t3BN1mfWY/hAcTUT1BQY' +
'cr9tWkwodCuJmNUsBLJzz3Y+nwXeNeZe0/dhWOhg306+62B8SvzouLGQ3/H6+BtO2OYoDCm6A+JkqSxu6IjVk+X+SH+jHoG/' +
'0XGBilUr+QAS+4R3DD0H6XOfSooPgIRqZ0z4KAga5BtrzwSq8eCBeaH9xvROgYRS522nBuZS7wxvCmw+eHLz6Tkgu2Q6k4Eg' +
'7NQjblW3jCijvGGlmNNVscqBZcf3jzjgguiKxtGNB0K/wDYXo+8BvR693wQHDmBP1wsDL46CZYv3j/xdgdnduOguAO1VaSPb' +
'ARdQ+TBOU4lGnBEE/15lheee5xCUdLp9uaw/TEvZuC/HF3ZnFuYWHWpIePelwsKicpjaa+OOHANK/G4fKosD+d5T4Tnc8P0H' +
'q1W1M7oYQf/RLDVdAWNVe2WHSwtLKc2AiYlrt60vh08WPB82pCnQihCawJyM3DXbfoKSrDtpd5PBNs7S3rIQ1AhpK75AM6Su' +
'COv35BO/TI6YtkEpBEEwqUTHjYYVG1aIXeLc5WwCMfawZ0M3gKmbx9Q5cFzwKbJtp2ZcAVWt3W2MQaD5al2lAxTppXm3UuD0' +
'pJv9bpYBnppawR87PrVzUqFZVYIRbgSqrYCGJkPZzDGOg22TVm65AFOX9T3ZZxEMn5YwO/4EeEvMXLM7qHR+5IV6SjIZzViw' +
'tNNma2Ww6Zsj5UcT4M2pmedXTwcj2WzijQF60ZU4tBqP1RoJikybP7748nZcnON0i0FUIQhVWAHB0XFzxMZm/hATFRYfth5Y' +
'rHayG3BhYNTz0hcbNmCKDKAfFF0tPVo6As4Mu558oxLwAooqHCgUDux0IPbKSmmUPuazcbsnlFGkolTk/LN4OMXJwF5QMzi8' +
'q9VctQBTJZiX1DU0ArBj5/9xHx0d5IjWWiIxLSNljryDBjWDDSud6VxxnJZyXW7MdErgqZEZqR/a7mJSTHl6PntUJ9VyRgF3' +
'yGZd83Yo3BiSzONCwwcftZZghpNcYuM5OSOXvnwFRTzO7+7/A1vMJNSBLSZHAAAAAElFTkSuQmCC'
],
// 24×24 — scaleFactor 1.5
[
1.5,
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYEAYAAACw5+G7AAAIGklEQVRYw7WXeXRV1RXGf+fe+95L8kImJiNIgWhCUqYQQaAE' +
'SBABRUaLRRRBQRQBIeKEigWWwFKGyGqxVFEGgUKJlUEQCgkkyJAIlCkEkBKWsKISg5i8hLx7zzn9gycJWpsE6ffn3Wef/X17' +
'77vPOaLzsI9TN74FZqY7yR1shBElC6Tq/4kIFvlix+T9vEwa4zoVk0BzYr1P4kLhIoH/F2wMbE6STyEFviW8xVzez52gLd1C' +
'93hnkM406hsDN9lqnH3UzlCnReem6yM39DNup73IEvue2SKyxHnyZzYjmWTRIyoSP5VUAg4ONqADgcQtJv7TfS0sXICbIDzA' +
'HjJ11uVY3UfHkDC9kFC9Qy9dHCXufnJtXsaVAecFIkeULLuLBGJEbGQlFVTocnyB7bw1EjACoYvwUQ7qfp1Mn2rmLSKHbUA0' +
'XkIAdZ1ybeAjmBARgpfjfKnPlGj9nW6lo0fvEYnW6hbrBmSm05EU7kt5Dhs//joQ/xHfUkEFcJjh/B6abgjtFbquKqcXBpbt' +
'KHsYSGQN64BGBBNc5zr5cOHChVfvZRu7skaKNguWH1sdUfYgDQkl3LsRja5TbgJQS3SxroDE/Eb+hi6YHdwtuMsQwI2JAdP0' +
'HnvfBjjc9Ft9qRyMcaKBqLsAEAgEcIkrXPHFWc42Z4n9mvePgEbdxIYBOP1VX/UIxB6LeCVsI9xxsd6x0Geq7LEREW3CWsGB' +
'py/OuTgArFXGcmP1zcdDIxDeZZbTzh5tP0+Hm8n6DQISdGf9AKg18t8qJPCxmgC1Wm5VWeA4TktnM1Ai9otPf0XAa39cF8tp' +
'5/Ryhv868gBOM9VW3QtqkUqULuB+LlW3q/UqUXYHZ5Ld1raB9kYv4xbEtZwMe7DduhYrw/DgAV2sp/AiqK36iDoNJNOc34D6' +
'iFE8DXKW7CfH/NxdzpL9ZBLYwj5kvw8yje2sBLIppBCMvqKtcReIhiKdt4EfAuO7RgEv21/ateh97dev6KlQX4d8EvI9DJma' +
'MCx+GlxOqyituA/+ceZkdEFTkLYzUc4GmgCbqgkocCbJ2WDa6nnVFgYXx7taLYXIrOBOwZnwcVD++pPT4Tt/+YDyqSDcYo6Y' +
'VRsBh22P/X3NC+U6dVjlwKDdib52I2Cmndq8Rzz4V8r98gJELwz9wjsKriba5c4RoPeN/q48USx2wfj1d29MOgNTP0w+2vUz' +
'cCeZ7c1xoKJkqXMW3u1w4HBeDpjDDI9RC16WGuIMdrrVvFB1UsP0ICiuVza5rAj8a+WfZAa4HzYnmENhSljXM12OQuHg71Mv' +
'HwdSmcgHVf7DZ7Wb0foAND8dsS/yHnAnmclmc/Dvlsvldii+WHbJNxBUgTNDBoO4w4hQS2vmJep3nD5m5kO6xhmkX2Er28Dz' +
'pjnXHAeTRnSL65oKaY/1OJXsAncD81Hz3uqKf3KeGIH5HYDtkZtkHsz/IfuhnHhYNChn7d4FUDlWTpDzQMylX/WT/BcroNfJ' +
'XLm55oUE0RgLrv7FKXMMSI/ffU92KPCm7qjHQFpaz8vd54C7gTnRHAJU4uBQdbcJwoULbI9cIXfC/LO7jmSvgXcaZAfnTILK' +
'nU6RLAPRTDQWGvQ6cqkFLxEV91LFqztrrsB1eHBhgT7P44wBd3tzhjkeJpf03NK9GNL2pZb0bAZur/mG+VS1jEfIRXItzJ+T' +
'mbIrDNKzd92WXQT+zXKynAOiJStYClRi49SaDZaeL3PlzRwoQTzLYKg8J3s7fWDhPTuXZM0AvVi5VRxMGdGrW8oIIJwggmHh' +
'Vzt3ZL0K6f6s3N1zwW/KTs5zIMawWgwBfZXP2VB3GiKy0ZS3X4itRQXqEUQQqDh9Vl8AVap3qr1ALI1pBLpIP8Zo8JRaTS0D' +
'Ut6L2xW7B/BgYkHWhFO9T/eESuWccXwgbherWA6c5mu+AaOe6GV0BeOUiBFNgVKucrU2FZgpc5z/1WsBaTpT/44ecOcLjV5s' +
'eAL6Ptw64rfzwFPfGmWlgl5ONgnANBazCeQi1V2NqvJvE337gugtwBwGMgDEWN5jL1RecJY5mfDZ346PPXEcvpz37UeXkkCk' +
'is/ZzS+9O66/HkRE+LPWpPk6BwCDnw1U/ZUu4TKId8VoRsKilx55fvgD8Jju2qVzwU203i9ghb03Z38MPLdgdfqabaDH62Ws' +
'BHGHiCLyv0rQaHZYerJzUG719cTBocJ7bW5UV/06x6kEVcp+HQ4H1bnz52ZCl5Exh1rWByvfnGo8A3q9ziWv9oTFQ6ITHcFJ' +
'kPPUu3Dog3OR5/4M6pSz39kA4nVOiK2gJ5CL52fUwcJDSFkLEe4ek/l0eGYQfk7oIykVgAsLX6BM3h/l6I/5goNg3mbYxhWo' +
'fzbUCM0Ca40x1OgDHKKQ83VIeYdrdyhnuMpQ2+C7mDJVlgLya+VS4SCGcDdJ1+kSaCYf1x63Xkxai7aZhSJs9hNhY5MHPEie' +
'HqsnfjgRmyKKonoj8BBU7WVmYWAAWZzkJKgCfa/uC+RwitOAGbDXFhKFApKJIxaMVmKH+AxIIZ54wAnYr8GHppKreLFoQnTJ' +
'dhLECvHX0W+Iek0ez37CJxxxSbehxfg4XcEwPXTma5RylH9FjULgxg0ITMxqZazKyq1E1b4aiQQ0fvxAKO1oV3JYeMgQGdPf' +
'VufFD+QvXinCmoycNvqfwKf23+2DwqezzXpmQf9ytus0/YfJWyhlFUs79cOhiG9CjwWKmnqLid8IQSYWTbitLIZQHmVk3p10' +
'E+liZfqjopl8Qd6/eRVPub52NVb2fwCpA26XWsL6QwAAAABJRU5ErkJggg=='
],
// 32×32 — scaleFactor 2
[
2,
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgEAYAAAAj6qa3AAALBElEQVRo3tWZe1TVVRbHP+d3Lw+BvFdQxBFSCEpdZoKFWZNP' +
'EtMxSM2ZLM3yuTRt0CQfxUxm0/QwTMx8lTmjVr4CZ5gymTI1a1mAGuPbIYeHiIJQoHLv73f2/HGvYM6sEsRpzXete/+4v7P3' +
'Od/v3mff89tH3Rm0JXzrXPzZqarUBS6y0OpltgVbhu8uv5zYN1Uua/jdxLbqKTVBjR/0CbdzD7eFbyACP9rbT2MgGPx80Cg0' +
'UISLEjOUr9nB18UPS7pkyLpth0iWUcxbnu0+7d5dNz3fbgTaRtm3gwyUfhKFv+r5xuZHs1aCetLoaBwyVqlfI9z26Ptqj1qr' +
'Pnl2NmNIYnBkf3yxYwAuXLgBjUYDoH5G+gACgIGBAfjiiw/gxkQD69nIX76NlnhJkPj5mXKX2kjRmn9ImPW07qd/o+Jv3dwz' +
'azuo9nK3RD+Wxnq1U72VsVCFcyNtA2s4Ty21V0z285O+OlFAEUAgASClFFFe24nHZLA8Me1BSVEulbR6gepx+P3UzU/FblJK' +
'LVaHNt+tehDF7ZFhuHBRR43XTVCzLU0jCMhrkse+hp/VDBVHd8BANbu8Qg1++OJHkOzmMHmFB6RQBkjP4VvterIVbyVNjOZu' +
'itRbkWFMQ7O73rT5iNdhYYGs4XGZDP4bbdW2ZQ2PL7Y0Y6wloMbytloG+GHD1myzN/B4H4Utshsf81dJm7jNriusXEsPms4B' +
'SsnlM7Q3Rp4oXHssLE/E9dsyjilw++iwA6HfwfgTXd/u8lXDDKtiC44crIOvA8tuKL8LjLFqFUsBWzNlhHi/DW+OuSnmTOJM' +
'u7nByrZWtt9AADbs9UObLwlflq/IBb8T9pG27jCuT5eDnXPgLrOdI2z5Zetrq+fIM7A/8/RD5T2hLsp833oASFV30KPZVqPq' +
'5fweF3Xhu+xWlZlprvZpSxUNpaMZIS/wHGsgsL99uzEEgmf6n/HbAYDj8nHBY/xP+qWB/Qk+lr5Q+4n1nPk3UIfYwqPNvy4E' +
'EJ8+dqub9by15DpMcAm3yAreBmu0rZ/VEuSchMmN/2U9NdJOOoCVaGZZu0F3NXtbi4EjaqK3FF8X2HVrM/V6CiAxzJCVYPWz' +
'tC0bJIwFrAPgE05dNm4K9zMCrDKri/UwWNlWpvk6qNakqgnXUQCrm/WsOe46ChAlGYwF/bC9l1UNdJcUGQF8fMXAmZIsQ0Dv' +
's9KtVWAlmF9YG0G1UNOYfB0FMKeaM8zxTbBU3k8NLlygv5GTUg46ToKlPRi5qlKVgBqiJqvvwHzM3GC7A+SwtJH/cnSWoXK/' +
'/ArMWWYvczCYLmuy+RVItgyT+0D38PrN8/g1blUdVCgQhC++ePd0EwTQOWZHa2ATBKj1Hom3cZRjcMvUNpNCVkPXGaFzQmdD' +
'wVPlr5WPh0Mp5R+cXQl6oH2gfTXwIS3ln8A0Tv7A3yL6yEDQ91nfWVFgbXNvMU3oPDo0uXUcdJ0ROjN0NhRMKf9j+Rg4knom' +
'q6InMJCOxACB3iNwYwWwPrUizIDGG1o7dYF8C12eDL3Y5hZYftvQ3CHHIeaPIV2CP4JvR1atrtoGT8/cNjmnI+SuK+lVGgpy' +
'q9QR6XVy+rIM6CYxRIE73213d4fe/TrcG7EPXtqVGJzgCx2rnWnOj+C4UTGq8iUYd+yDk1vj4KBRfuHMSbD1MW5SkY2mgd1K' +
'NN81FzXe0Jyi39Nl0OWdNntCboeYliEfB7dreN5xg7PQmQivJgyadG88zL/w90U7PgexZLzM+U9/UiehEgJ9P+qY2iERftdp' +
'QFrfRRBR4tjq6NQwLlqHRAc/DV3OtpkaMhYOjCntVboXSDTeNcKaIIC8ql/SLzTeUOXovVIA3wSULj91BArnVZ46lwqRLwS3' +
'a/UycNbzEhWR41juaAV/GJv4VMLN4Pdn25/t7f7TX4e1zhVOJ7w4OHFawp3QpiRwa2CnBj+0JpBAKMw853MuAwrGlK4pmw3q' +
'Lf1rCQMZSE99axN4hMSlfTX/Q2l8+aihDhfobIYyDPqH31QRtR3S8++v+9V6iOjl9HesBCqo5TwQ4nkrq0etp3jWI9BbzC7h' +
'CrviG6s7Vy+FlOFbw7OfhL9PP55/QoExiCw2AUH4/cD+qgVo88zjvx/cBAG8h2U5wCnKQBfIfjkMCYkxMdEaXvs8aerQMIjo' +
'6XzQUQic9xIOuILolbhiXHFk1fHqYZAyPGvtXy5AzgPH3jn+BRg3qS7qJlC30Y4wmnyKVcEZc1zP7m2CAFcIwT5KOQX6gHwt' +
'BTDggZv3x+yB9OgHTtz/KkSMcK5zdvoRIa74vaiu6sVqO8zw/+CerIWQ0/to5TEXGDGqm7oZiKU9v2g68QYBgp9+fd6waxCg' +
'QQiFAtlHCaWgC2SP5EFCxs0LYn4L6ROGD0kOgohk5/fOZy4jfAmXiLureldlQkrelgOZTsipPRJ7bAgYMSpedQMVSzjtAfG8' +
'ZV4r7JKn37QWXrujemiPEIaLEkogp9ehoMN7IWXCpvNbboH04uGfDesFEU+06uP8osGs6Oy5C1XTIWXtZseWdMipO7zz6Btg' +
'dDJ+qbYDIqdpD5LbPMTr49YqcsanqaObIQNMLDTgxsLk8owopgS0QwrlFPRdETM5ejpMT+of3feZhnGLz35q7ngPPut5NO34' +
'LFBFqp1ygoolgnAaIu7jfW23Y2uOZqxq1eq3I2dWNEEABy1oATpLenAnSKWMkFFgbFJ2pcHbeGg4MhdTxTnQiaLFFwLe9d3u' +
'e1k/4PxI1wDXBDC2K5sygQha0YqGI66nUYMeIaYYoILVJrUejCSVy5dANRe40HgB7DLOel0vaLyhfMY90g/CklrOcnwDj7a+' +
'K/jOOIh4JPhfwTNBklkqb+DpzloesQGwPETIYetlzVboz0EGAPd6hbtU3nywYwOVyRQ1FYpmVd5YuRDWnNnT78vZUBb63eLq' +
'HFAj2KU+bUIGOB1Tp0z/Xi61ln8apqe3Z2XpJbIK5uwdUnlfNMyZMyT0vqGNX0BT8WJadsmHG+HF3tltPywGW7LxhBqPZ2tc' +
'XS9RELDLfr1cL3Gv9aSYzyM/LYB47gMy5RXJAI5ImYQDsJD/oQAckZfkeSBIh+tykHxZps4DdmVcVSAVCrt7rp3+1gortTgE' +
'C5PqyEsPf6z/vwKAHXIv62H1iJ1xu/JAknSYjoeIuSE7gh8GkiWdxXiKonUNRH28Ec1UKUyHoj9U9K1cB+8s2r3k8wJA6Tzd' +
'GeirVjAVgGU/4s1TUQSFD0E4ig3lCBk/elLWmw5qOUDe5Cqo34M/Dqe3CGZKV4kFqZAHeQiMLSpEBXm9GN5Jr+0/xtPG1Aga' +
'9DCpkBpQIWoj74KRrApUPlB11UXQU4MMutH9zcOq5YbHR074fayTbfIK6ZsfooYiKY9cisIHn/pu3E/fD3hrA65rjPhPwdeb' +
'EVe/1wFqENy4CaIFHVW7wlHcoeYzd9hKOwlWou6eX8Vo/AmZ3x2D92RNxhZsnKEsaBgG/rSod/T/dzWmuchFgrBoQ9uaPDnP' +
'AtVy/ggjkShO7Auy05ZFMgukQFfosj8dU/HEEUWB7ONZ1qfNoFjy+TLyWwTtPeX5eDsvzX+Fda2kPTvcjRsF3svSX6gexBcu' +
'UN14jbT5IznMIm7400G9wVhpFIC64cyY5x5Lww8tSTKPOvbadhuTgOfcYWZq92WUsJR5E/ejZaO8MygSTQHfhLvR1FLnM/cH' +
'ev9c8NBtQQt3Gnbi6FwcgFYPqke2LSKAScxboTnkc9QnKf8MxdYvrWOAQ21UH+D3b76Wt7Ld39jVAAAAAElFTkSuQmCC'
],
// 48×48 — scaleFactor 3
[
3,
'iVBORw0KGgoAAAANSUhEUgAAADAAAAAwEAYAAAAHkiXEAAARyUlEQVR42u2ceXyN19bHv/s5zzkZREKMQVBVV7X0ahERQ2ip' +
'0tZYQ2ue1avKpaa26C2u6d6iWqrUVDVUDZ1UK5FEhFCtWc2toIYgkeHknPPs9f6RE1zD5baJuP3090eSc55n77XWb61nP3ut' +
'vXcUNyC879rgL9zYZDalOYhl7MehPgRjhXFITfNvysu4VXDkP9S/eIGX2wYRTjg1IxZQhYaUL/OkqkQAlfyqEIxLBaki/FFw' +
'EYekSLIcIp1Dmfv4iU0cSlpADGuI3XJB3pAVRH06mtPsku3Ri3WaLildM2ZJcw5KADCFmjTHltClZclnO2DldKty/gh7YdWp' +
'de9gVweMWioDt+Hr6WjtBQbYJ9uL1KqlLqjp9B3WQ03kZdWySSAPq/KEBnbE9PZh4cECNIIFCCDIjXL+RyEoQKEwUNgAGyY2' +
'wOO18pAc4WzqaZnKe7Ls235SUuoybkoCypPiydp2Qe+3TTEag7SS6tIa+7Zyrac+Xxa3qlVl5b7Vl7Gp08ZoYxeWJ47aRrx6' +
'zBFmfMWRThV5n5FMmtCXJwhVVcs0xoWLLMCNB+sPQ/DvhcbExIaBLw58gER2yfZTkbzMQPqNumBVJ1mlLS6intffaY/ESoB0' +
'19HYVK0an5ZbUxKMjepvqg/IapYinVeqL3mXQTOjqUgohYNmeYnP8gr0yW+L73Nk4cCBDz6c4FcupcyWnkxjzsAC1OSSrre4' +
'i8RIdWMhqJrfrvhydQNQvWiIrrWUN9XbaseqZjxEBSqWCcKNCw9pZEd5QH5b9j8EAdIwcWASwGGOciLpvLSXvtKkzQfs4xQq' +
'8XXTmiuvW3P9axqabipqWDmOSkOWlgniJyzkasT/SfxvQw5vWRiUQ5UpJgG8S9SwLGkjJZjetbAplbSS5xpetF5hl/RuchBf' +
'9vE5dbwv0ftnqLFjYICclLeYBPqYBEqpa5eNCipVnQYVqt5kOOBGo/Nb6avwQaFQQBzpZDZRNGAmyZHRpmzWl3XFNus4yQXS' +
'AqsgCGlXVTfyW3Mc2LCBniIdpRsUaeX7g+9FqHK2yITghddu218iedTFXpD8vTPS2RyM19QytRBwYV2b9OU7NKAgcBzHKEGL' +
'tj1MPdVK1OcjmqkwirCbEwiC3AfEeyNGt6CBPA0VqgStCKoKo0eEZdV4Bf7av9jkolHeewV+nHd+woX5ML7ZtuAddeFYqZSL' +
'KaPBWEuM+ga8duU3jBy7ZA9nOFenm3q05YJ1S5LS++CPYPrPyW8Nr+I4qaQCUbRXbWB0wTBV41/QwV3ZrPTk7Zstq/5T8KEd' +
'MH7b1vM7ugORfCKfAQ8QSGB+G8XVwOISTjIzipg6TR/Xu/wG4L6aUnEfRAryETvYCb6nzN7mAahYudDPQTUBKH5LHb26V3QG' +
'7Q16AOy/GAXUfnB+5HnRWg7qb9Tg8fy2yqupApy4cfl9amrRG/V6VY1Mr1nqenPyD3KBeBJAftaVdDlQU1Uv1Q6Aday/vYbq' +
'HdVPvQSSqP+iHwBdTq/Ta0E5SeZcflt1zbxs/VVDUz9iLdbzrumf35pd1fARNssW0EVVNWUDmslW2Q0cvUPDl6WLdAH9lVXb' +
'qga6hLXbskA9Ql1VJ7+tuoqrPJu6lF5v/Zrf+twCIcQRD2JpS4eALGcYYwFoy4u3byZTs++TqnqGXgli0xv0GZCxpHEf2mlq' +
'S8/S7+W3GreAR2LYDDrY6KYfB8bL/8lAoNUd2nnv04X1Kv0Z6IvWAr0TsFQDduW3UTfD1PWtaXppfqtxC9RlqsSBfkgvNZqD' +
'9JRUtnivDbx9M5klA3gV9DwdqOuAPqwftb4E6jNN1ctvo26GqRN1qvVqfqtxCySSwiug7dpuTQJG8pYsBKKY8B/bjeQt6QXa' +
'1HarK+hEnaJfARJJ5T6007Ta6056072QlF1KoId8xlrQs2kiz4OaRyueA/WMWqGWAGUpRBCwW74jGqwOhqnrgpyRDjL8zmKk' +
's3SV4WCFWD46CqxSVjsdDXRUTwHwC5dJAfla2kknkJ6s5nMw+rFBrQPmq9a0ADz3ppRhSrI1zJqUhxJ8MTFBqksQJcC/oT3U' +
'XhKKFw6YWOAyZCa7i7svw7nd6QHpI4DObGUbUI3hNAPJ1DHGu0A0G1kJdCeWb/6DvGh60x+kld6lM0HS9PfWFCCS0vwKDKM1' +
'YVA8uUBkgb3gV9hey34Zzqm00HQ/yHjAPd69CNSPKoWzgBMLT146oInVV9fIi56zI14/QWEJgcqVis4v+iAMX1R/SsQXUD05' +
'pE5IQ7jyYdaRrHRYXOXHxrsPwcK6PzT5cTFkVbSGWO+AdDXGG21AzkgBuYuarLwv3aUdSIiVbqWBNdKabi0HH5dtnK03dP1n' +
'dcdfW0Pn4389Uu0RKDjU50GfffDDojNTzkTDpHax9eLbwcERF/pfeBaMRC6q0+TZE6GK95/QafJSyfXcV3ryGWvA/xnzmH0N' +
'vLejxYzm8dCsbKXxD93C4a5F1lprB/xrbnxIQmWYMSfh5LZkMKcYe43NsMLZ8e12vSHskzLu0hm3l7utY5L9lD+08//kjRUf' +
'gqeXLq8fh4GFwpPCfoEhn0eEhvuC4zVbZ1v9m9t/XenQ+4fPQn9Z2/7LByHjE08Zd3NQ82lDy9x3gKkL6Q16Yu53rOdLhqRB' +
'sRaBVmAPeLx7yPiQucBGoAbXSglZePCAo4utha0GDF4RMTr8A9D7ZYtsgw/Dtj/1vQd0uE7Sd0rCAD1an9LHwVyiJqqe0O//' +
'wkbUGg1DHBHDwgeBI8E2yvYqkJktN2eIzEmNqoeE9A55E4o96H/GrwscW3DJ6VwPRiG1QR3IAwcwWzfVHXK/Y6ZLPXkKMpa4' +
'nsp6AFJjswKcWVCSgokBkF0LcQN+2LEDmbjxgOML23hbHxiyoe7DdVaBXzHzsBkCPiHmIDO7FLGMuNuL9cHsYTaFgZfDfwmz' +
'oH+32qVqFQLHQNsFL/FuPF65pvfzdXqkHspq4pwIGQGuC1kK6KA/0N2AQSpOfZf7NKki6WMPvH0294cgTmbPNjibXdXsUzjs' +
'aM1gGDuz8YEnl4HjA9sM28ibCbiRIPcqXVbXAx0oWQL4NLadtf0HB2TNt3ytamCkKh8F2F81Uozdt5Fz3WfXHGuytQjGzfy2' +
'XtQrMGf+tszEGCAIf/yBUO/sLNcd8PiYyn8vmwcOMLx17xayUJaAw23Ws5WGgfUiutbpB0N7NTherxA4Ttg22JbfgqCchRTv' +
'gsw1hr1Dh4936LjT9zf2cyPxva0B1miYFhbzetyzMCMgvs6W6uD6ybPMSgT1hequupC93SYPqsQmx/UI3TP3O86Beo9DxILr' +
'gtvHPQhmvBZ3MX4HsEbaySAYWjqyav1nwHHKtsf29S0ccSNuJPhO39+O+GZWE6snTGsf833sezBjSlzAlrrg2med92wEFcIR' +
'tRlwynDpkXf8mDh0m7x0QM7UTZWiDWngDta93Q1hRnpswc1u4GuZJ0NhaMWGVRs0BccR2x7b+rtwxN3iRuL7Wa2tgTCt0yZP' +
'7B6YMSp21ea54DppzbXKgCrHBTIBJx7pCjjykBvAlM9lmDTKWyH/Bh8KYQdXlLuyOxJmpMS+GFcK6CsbZQEMLdeoVGQEOH62' +
'nbbF89sdcSPxz1k1rBdgWvHoajEFYXrdWHdcHLhmeYKsOaBCVbA6DJLFYOr/F3J+rwN4TrfXo+6hA7xQvlzBDq5zbssTBjM8' +
'MQvimgHP48sJGFq4UUBkFXBcsqXZ9nP3jriR+FZWLasFTDsQ1XtTI5j+S4wj7hi4KloTrGBQpUhnLuCUNjLs3vNg4pZBOj+q' +
'hO7sX6oQAewF1xl3gO4N05dEH44ZC3SVhjIThj7wpL1hOXAct7ltP3N7R9wc8eWscJi2d+PS6P4wvWhM59jSkJXgGWR9DkY5' +
'FcQB4AoD8rLUcGcHaN1FBv7+jn4zspdCUSXR+ILLx33FXQKmL4t+JOYU0FzqSGsYWu5JV6MQcPxsOmxnriM8BznEd7ceshrA' +
'tH3fHYj6BKb7bpoc2wyy4j3bPePBCFUfqwKAUzrSPx/t9sKU9rpfniRivxFqMCbp4DrnvujOhOk+UQM2FQR5TQZLJAzt/1Ro' +
'o6Xg8745wrxuZSxrnOddz1cwLeC7hKh5MH1gtBnzJbi2WHuto6BeIwAnSAvpKW3z28prMPlY+upm+a3GzVC++PEruC64z+ke' +
'MMOMCopeAqd3Xh51ORJazH5scrVxXC1prF2+K213cfi0wc7LPyZA1iBPbc/zYExQAZwHnPT8tyfmPoEqHPzqN0N75UEilgMD' +
'UCDnucIVoApjZBzIFPmGb7masN0WObWa/ixjBegWgviC7yr7P83rXprO1u4hnslgrFWinMBsOtIecHoTtNvBm2CpYeppGgP7' +
'GafGgCpGQQqSPY3Ow206qpBrUOUh83LfAcrhXQcYI2tYByzMXmQPPObn43sAfB+zd7M3Ak7KJS5y50wzx0nu7MxWVvC9/HDd' +
'5XY8oaoDdm/idSeLchwfqgoTDM5d7gXuKEitkJnlfBjoSj0iQI1TLXkexHUHR/5GmNTXeTL90kfZKolgb24E2nZAp6bhe8KG' +
'QOeg2n8Pi4fADn4r/UzgFybxM2Ch5W7q7TmOaH/T91GchLsmSWEoA6jNSopDalDmU5nxsPilrW9sGwxLlic035YE7qO6hBUB' +
'qj61Va3c50kVChyQOKibZEefyr0TL56LOkQ/BJGNK1erFAYfz+hTq1c0BFb1Xe/799w3JLeQ+oOzsXMkvNT/g60fRsCmzQcP' +
'HNoDZrBxxjica2LE+1OZ+MgI3VK+RXBjqsa5JiFdD9CjoPK8kmVKLobACr7rffdzbWUpZ434foFXr8Dqvt/6ToTK75d8sGQp' +
'iE7fH3+wGOCjZt1hO8B/g+xzZiIfmRKr28rwjGJ4UOgCuSfhRxEJhYONTrU6fRxS450FnLUhsLZvuu/We8/vHeENiNTNTn9n' +
'GByoeerV0zZQW0WkKEi0flEG56I8jcaRcdykoe6tI5JmoZQPzr/MJZe2JxpL2EICJAQcPnp4Obx5atVfVr8EnddENAhPgaA+' +
'flX9PCAr2Mp2wLrHByps2YSrdtSmJqR8kLkn04TFVvzDCZdga/HDAYdXg7GaCrQDmkgdHZ4rkrOHHxN//JMmqaCQ3r37xcyL' +
'IgMhuUdDBJUr7wJH9jFOeZNVrAEWyza2QVCy/3i/luBTy5xtjgVOksxF7v3+/Zxt4qEUIRiyEj39PGMhpUjG6Mw1QGcVRhio' +
't7xrwS7vMdzfS75CEBSB+FBwfleTgrJIPlo1ApfEs6WtiYWbrMD430n/1dmIGkNXbx5whCuQUiV9Z3oRkA5ynmbcOQ/IO2S7' +
'W3MeAbVT7eQZIIEj6iyoYrKOaLIDI4rcOSskKCzs+KRWJIknqPnpDhX0bI/ZvX/1+14a8QWTFp4giSTOvNAasGHD5W2ax1Xx' +
'PzxcgMbCQXEqU2HlOF6iKW90DTfFIZtkeeYTjCGBj6d8hch02R7+OJpTXCqzE8HEJM3b0Z+nJf87pKGw8BCARRmKJtWUc+p1' +
'1XRKT1WHY5TPfNrE0u9oAZnDx+zY3kz15yvWjmwArGHWzB0YkkJyoRooZcf+5xNxl3CBuHETgKWCKJpSAOjGSyO7qsoyUGK2' +
'95f9vMwxMNFqsvoMgwb6H7oFWveU2pZesslYzCZjJSVJZwDfTDgqqfIsJ0MLoHBgBxQGtj//VYEXGkGjMRBcuHCoAP5GmaSe' +
'2GUXrUbO1eN0AVp+PES1tmUaH4Eqa4xRj2JcJa7g2i6R3ctjUkuayHw8lLeVMJoBcZ411rqadZhFHKOHLiVJEmTD01m4Oc+Z' +
'oErXKZFdg1H/9lL9ozjomh3XZmvZuy3U1StFKZlak6Kqrmr0zdt0pgl9pjajn+1zo2miJkYv5lHgtOrMe9ivRC5K/6gb7puI' +
'CWzeqWS38hhUkP08hpZeqoN0ATmr++sH/QbzJGUpHrlPFWYvX7dNBuKIiwDNcQ6X6YImDfxLIHhwqfZ/GPIVJg5Zhg0ffDJ+' +
'wkY1QpImY1CfevEz5RSPErFqA+PZzq7onqqVsdEIzGyiajNBTQRxUpoT2K7YlqgFZ65NaP8foNDayppt+VIAAAAASUVORK5C' +
'YII='
]
]
+350
View File
@@ -0,0 +1,350 @@
import { app, net, shell, type WebContents } from 'electron'
import { stat } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { UPDATE_HOST, RELEASE_API } from './config'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'
// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers lives in
// config.ts (CC11) with the other build/host constants. The updater reads the
// repo's latest release over the public REST API and downloads the installer
// asset attached to it.
// Security: only ever download or execute a file served by the trusted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
// release downloads to its attachment store — so this is re-checked on EVERY
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
// response from bouncing us off the trusted host.
export function isTrustedDownloadUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
} catch {
return false
}
}
/** Where downloaded installers are written (and the only dir we'll run one from). */
function updateDir(): string {
return app.getPath('temp')
}
/**
* Parse 'v1.2.3' / '1.2.3-rc.1' into the comparable numeric components of the
* release CORE — the x.y.z before any '-prerelease' or '+build' suffix. Dropping
* the suffix means a prerelease (e.g. 0.5.0-rc.1) never sorts ABOVE its final
* release (0.5.0): at most it compares equal, so we won't offer a prerelease as
* an "upgrade" over the same shipped version.
*/
function parseVersion(v: string): number[] {
// 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))
}
/** Component-wise numeric compare: -1 if a<b, 0 if equal, 1 if a>b. */
export function compareVersions(a: string, b: string): number {
const pa = parseVersion(a)
const pb = parseVersion(b)
const len = Math.max(pa.length, pb.length)
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0
const y = pb[i] ?? 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
interface GiteaAsset {
name: string
browser_download_url: string
}
interface GiteaRelease {
tag_name: string
body?: string
html_url?: string
assets?: GiteaAsset[]
}
/**
* Pull the SHA-256 hex out of a checksum file — a bare digest, `sha256sum`
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
* lowercase digest, or null if there's no standalone 64-char hex token (so a
* longer run like a sha512 digest is ignored rather than sliced).
*
* 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, 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
}
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
const exes = assets.filter((a) => /\.exe$/i.test(a.name))
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
}
/** Query the configured repo's latest release and compare it to the running app. */
export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const currentVersion = app.getVersion()
// Bound the check so a stalled/unreachable server can't hang the UI spinner
// indefinitely — mirrors the timeouts on the yt-dlp calls.
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json' },
// The API lives at an exact pinned URL and answers 200 directly. Refusing
// redirects keeps the check pinned to that origin; a stray redirect (e.g. to
// a sign-in page) fails safe into the catch below rather than being followed.
redirect: 'error',
signal: controller.signal
})
if (!res.ok) {
const auth = res.status === 401 || res.status === 403 || res.status === 404
return {
ok: false,
available: false,
currentVersion,
error: auth
? 'Could not reach the update server (it may require sign-in for anonymous access).'
: `Update check failed (HTTP ${res.status}).`
}
}
const rel = (await res.json()) as GiteaRelease
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
const asset = pickInstaller(rel.assets ?? [])
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
return {
ok: true,
available,
currentVersion,
latestVersion,
notes: rel.body?.trim() || undefined,
// Only ever hand the OS browser a release page on our own trusted host.
htmlUrl: rel.html_url && isTrustedDownloadUrl(rel.html_url) ? rel.html_url : undefined,
downloadUrl: asset?.browser_download_url,
assetName: asset?.name
}
} catch (e) {
const timedOut = e instanceof Error && e.name === 'AbortError'
return {
ok: false,
available: false,
currentVersion,
error: timedOut
? 'Update check timed out — the server took too long to respond.'
: e instanceof Error
? e.message
: String(e)
}
} finally {
clearTimeout(timer)
}
}
// The in-flight installer download's canceller, set while a download runs and
// cleared on settle. During the pre-download checksum phase it just flags the
// cancel; during the download phase it routes through `finish()` (abort the
// request + clean up the partial). Each phase clears the slot only if it still
// owns it, so a superseded download's late settle can't knock out a newer
// download's canceller (same ownership rule as L140's releaseActive). (B6)
let cancelActiveDownload: (() => void) | null = null
/** Abort the in-flight app-update installer download, if any (the update card's Cancel). */
export function cancelAppUpdate(): void {
cancelActiveDownload?.()
}
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
// to run an installer we can't verify against it. Note this is INTEGRITY +
// defence-in-depth (corruption, truncation, tampering-after-publish, a redirect
// bounced to other storage), NOT protection against a fully compromised host —
// that host serves both files, so only Authenticode signing defends against it.
const REQUIRE_CHECKSUM: boolean = true
/**
* GET a small text resource from the trusted host, re-validating the host on
* every redirect hop (same discipline as the installer download) and capping the
* body so a hostile/huge response can't exhaust memory. Used for the .sha256 file.
*/
function fetchTrustedText(
url: string,
maxBytes = 64 * 1024,
timeoutMs = 15_000
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
return new Promise((resolve) => {
let settled = false
const done = (
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
): void => {
if (settled) return
settled = true
clearTimeout(timer)
if (!r.ok) request.abort()
resolve(r)
}
const request = net.request({ url, redirect: 'manual' })
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
request.on('redirect', (_s, _m, redirectUrl) => {
if (!isTrustedDownloadUrl(redirectUrl)) {
done({ ok: false, error: 'redirect to an untrusted location' })
return
}
request.followRedirect()
})
request.on('response', (response) => {
const status = response.statusCode
if (status < 200 || status >= 300) {
done({ ok: false, status, error: `HTTP ${status}` })
return
}
const chunks: Buffer[] = []
let size = 0
response.on('data', (c: Buffer) => {
size += c.length
if (size > maxBytes) done({ ok: false, error: 'checksum file too large' })
else chunks.push(c)
})
response.on('end', () => done({ ok: true, text: Buffer.concat(chunks).toString('utf8') }))
response.on('aborted', () => done({ ok: false, error: 'interrupted' }))
response.on('error', (e: Error) => done({ ok: false, error: e.message }))
})
request.on('error', (e: Error) => done({ ok: false, error: e.message }))
request.end()
})
}
/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
if (!isTrustedDownloadUrl(url)) {
return { ok: false, error: 'Refused to download from an untrusted location.' }
}
// Derive a safe .exe filename from the URL; fall back to a fixed name.
const urlName = decodeURIComponent(new URL(url).pathname.split('/').pop() || '')
const safeName = /^[\w.\- ]+\.exe$/i.test(urlName) ? urlName : 'AeroFetch-Setup.exe'
const filePath = join(updateDir(), safeName)
// Resolve the published checksum up front (tiny, fails fast) so we never pull a
// ~300 MB installer we'd then refuse to run. The .sha256 sits next to the asset,
// so its URL is the installer URL + '.sha256' — still on the host-pinned origin.
const checksumUrl = (() => {
const u = new URL(url)
u.pathname += '.sha256'
return u.toString()
})()
// The renderer's Cancel button is live from the moment this is invoked, so the
// checksum phase must be cancelable too — without this, a Cancel during the
// fetch is a silent no-op and the full installer download proceeds anyway (B6).
// This early canceller only flags; nothing is on disk yet, so there's no teardown.
let canceledEarly = false
const earlyCancel = (): void => {
canceledEarly = true
}
cancelActiveDownload = earlyCancel
const settleEarly = (result: AppUpdateDownload): AppUpdateDownload => {
if (cancelActiveDownload === earlyCancel) cancelActiveDownload = null
return result
}
const sum = await fetchTrustedText(checksumUrl)
if (canceledEarly) return settleEarly({ ok: false, error: 'Update download canceled.' })
let expectedSha: string | null = null
if (sum.ok) {
expectedSha = extractSha256(sum.text, safeName)
if (!expectedSha) {
return settleEarly({ ok: false, error: "The update's checksum file is malformed." })
}
} else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) {
return settleEarly({
ok: false,
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
})
}
} else {
return settleEarly({ ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` })
}
// The streaming itself — redirects, hashing, timeout, truncation, teardown —
// lives in streamVerifiedFile (lib/verifiedDownload, CL4). This function stays a
// linear pre-flight: trust-check → checksum resolution → stream → done. The cancel
// slot's B6 ownership dance stays here, next to the module state it guards:
// ownership moves from the checksum-phase earlyCancel above to the stream's
// cancel, and is released only if this download still owns the slot.
return streamVerifiedFile({
url,
filePath,
expectedSha,
// Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store).
isTrusted: isTrustedDownloadUrl,
onProgress: (progress) => {
if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress)
},
onCancelReady: (cancel) => {
cancelActiveDownload = cancel
},
onSettled: (cancel) => {
if (cancelActiveDownload === cancel) cancelActiveDownload = null
},
// Preserve the update card's exact wording (the generic defaults are for other callers).
messages: {
checksumMismatch:
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.',
canceled: 'Update download canceled.'
}
})
}
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
try {
// Defence in depth: only run a .exe that sits DIRECTLY in our own temp dir,
// never an arbitrary path handed over IPC. Compare the parent directory by
// equality — startsWith() is defeated by a sibling like `<temp>_evil\x.exe`.
const expectedDir = normalize(updateDir()).toLowerCase()
const target = normalize(filePath)
if (dirname(target).toLowerCase() !== expectedDir || !/\.exe$/i.test(target)) {
return { ok: false, error: 'Refused to run an unexpected file.' }
}
await stat(target) // throws if the file is missing
// 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 }
// 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) }
}
}
+9 -3
View File
@@ -10,17 +10,23 @@
* (Call sites also pass `--` before the positional URL as defence in depth.)
*
* Throws a user-friendly Error on anything that isn't an http(s) URL.
*
* Returns the parser-NORMALISED URL (`u.href`), not the raw input (audit F5).
* The WHATWG URL parser silently tolerates interior tab/newline characters and
* leading C0 control bytes (which `String.trim()` does not strip), so returning
* the raw string could hand a downstream consumer — argv, or the sign-in
* window's loadURL — a value subtly different from the one actually validated.
* Emitting `u.href` guarantees callers use exactly the URL that passed the check.
*/
export function assertHttpUrl(raw: string): string {
const trimmed = (raw ?? '').trim()
let u: URL
try {
u = new URL(trimmed)
u = new URL((raw ?? '').trim())
} catch {
throw new Error('That doesnt look like a valid URL.')
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new Error('Only http and https links are supported.')
}
return trimmed
return u.href
}
+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)
}
}
+136 -44
View File
@@ -1,34 +1,58 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath, 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 './core/ytdlpPolicy'
import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock'
import {
isYtdlpUpdateChannel,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus
} from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end.
* Ensure the writable managed yt-dlp.exe exists, copying the bundled seed in if
* it doesn't (first run, or after the user/AV deleted it). Best-effort and
* idempotent — when the copy is already present this is just one existsSync, so
* it's cheap to call before any spawn/update. Does nothing if the seed itself is
* gone (a broken install); callers surface their own missing-binary error then.
*/
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
export function ensureManagedYtdlp(): void {
const managed = getYtdlpPath()
if (existsSync(managed)) return
const seed = getBundledYtdlpPath()
if (!existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent */
}
}
/** 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() }
}
/**
@@ -37,31 +61,99 @@ 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 { ok: false, error: 'Unsupported update channel.' }
}
// Self-heal: restore the managed copy from the bundled seed before updating, so
// a deleted yt-dlp.exe is re-seeded and then updated in one click.
ensureManagedYtdlp()
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
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() })
}
)
// L139: `--update-to` rewrites yt-dlp.exe in place. Take the spawn interlock so a
// download/terminal can't execute the exe mid-swap (Windows sharing violation /
// half-written binary). If a spawn (or another update) is already live, defer
// rather than fight it — the update is best-effort; the startup path retries next
// launch and the Settings "Update now" button surfaces this message for a manual retry.
if (!beginYtdlpUpdate()) {
return {
ok: false,
error: 'yt-dlp is busy (a download or another update is running) — try again shortly.'
}
}
try {
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() }
} finally {
endYtdlpUpdate()
}
}
/**
* Startup yt-dlp maintenance: always seed the managed copy, then — if auto-update
* is on and the daily throttle has elapsed — self-update it to the configured
* channel. Whether it changed is decided by comparing `--version` before/after
* (robust against yt-dlp's localized "up to date" wording). Status is pushed to
* the renderer so the Settings UI reflects a check it didn't trigger.
*
* Best-effort: every failure path still records the attempt time (so an
* unreachable update server doesn't re-hit the network every launch) and never
* throws — a stale binary must never block the app from starting.
*/
export async function runStartupYtdlpAutoUpdate(
send: (status: YtdlpAutoUpdateStatus) => void
): Promise<void> {
ensureManagedYtdlp()
const settings = getSettings()
if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) {
return
}
// L139: never rewrite the exe while a download/terminal is live (e.g. an auto-
// resumed persisted-queue item). Skip WITHOUT recording the check so the daily
// throttle isn't burned on a deferral — retry next launch. Re-checked right before
// the rewrite too, since the version probe below awaits.
if (liveSpawnCount() > 0) return
const channel = settings.ytdlpChannel
send({ phase: 'checking', channel })
const before = await getYtdlpVersion()
if (liveSpawnCount() > 0) {
// A spawn started during the version probe — defer to next launch (no record).
send({ phase: 'current', channel, version: before.ok ? before.version : undefined })
return
}
const result = await updateYtdlp(channel)
setSettings({ ytdlpLastUpdateCheck: Date.now() })
if (!result.ok) {
send({ phase: 'error', channel, error: result.error, checkedAt: Date.now() })
return
}
const after = await getYtdlpVersion()
const changed = before.ok && after.ok && before.version !== after.version
send({
phase: changed ? 'updated' : 'current',
channel,
version: after.ok ? after.version : undefined,
checkedAt: Date.now()
})
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Interlock between the yt-dlp self-update (which REWRITES the managed yt-dlp.exe)
* and the download / terminal spawns (which EXECUTE it). On Windows, spawning the
* exe while `--update-to` is swapping it in place races into a sharing violation or
* launches a half-written binary (L139). This serialises the two:
*
* - each live yt-dlp process registers via acquireSpawn()/releaseSpawn();
* - the updater calls beginYtdlpUpdate(), which REFUSES (returns false) when any
* spawn is live — the update is best-effort and daily-throttled, so it simply
* skips this cycle rather than rewrite the exe out from under a running process;
* - a spawn that arrives while an update holds the lock is HELD (not failed): the
* IPC layer awaits whenYtdlpUpdateSettled() before spawning, so the download just
* waits out the brief rewrite instead of erroring. isYtdlpUpdating() is the
* synchronous backstop for any caller that doesn't await.
*
* The main process is single-threaded, so these plain counters/flags need no real
* locking: a check-then-act sequence (e.g. await whenYtdlpUpdateSettled() → spawn →
* acquireSpawn) runs with no await between the settle and the spawn, so no update can
* interleave. Pure (no electron/node chain) so it's unit-testable in isolation.
*/
let updating = false
let liveSpawns = 0
// Resolved by endYtdlpUpdate(); recreated per update so a held spawn wakes exactly
// when the current rewrite finishes. Only meaningful while `updating` is true.
let settle: (() => void) | null = null
let settledPromise: Promise<void> = Promise.resolve()
/** True while an update is rewriting yt-dlp.exe — synchronous spawn backstop. */
export function isYtdlpUpdating(): boolean {
return updating
}
/** Register a live yt-dlp process — call immediately after a successful spawn. */
export function acquireSpawn(): void {
liveSpawns++
}
/** Deregister a yt-dlp process on settle (close/error). Never drops below zero. */
export function releaseSpawn(): void {
if (liveSpawns > 0) liveSpawns--
}
/** How many yt-dlp processes are currently live (test seam / diagnostics). */
export function liveSpawnCount(): number {
return liveSpawns
}
/**
* Resolves once no update is in progress — used to HOLD a spawn until the exe swap
* finishes, rather than refuse it. Returns an already-resolved promise when idle, so
* the common (non-updating) path adds no delay. Bounded by the caller's update
* timeout: endYtdlpUpdate() always runs (finally), so this can't hang forever.
*/
export function whenYtdlpUpdateSettled(): Promise<void> {
return updating ? settledPromise : Promise.resolve()
}
/**
* Enter the update critical section. Returns false — and does NOT take the lock — if
* any spawn is live OR another update already holds the lock, in which case the
* caller must not update. The updating check matters: a second concurrent begin
* (startup auto-update racing a manual "Update now") would otherwise replace the
* settle promise, letting the FIRST update's end() release spawns held by the
* second one mid-rewrite. On true the caller owns the lock until endYtdlpUpdate().
*/
export function beginYtdlpUpdate(): boolean {
if (updating || liveSpawns > 0) return false
updating = true
settledPromise = new Promise((resolve) => {
settle = resolve
})
return true
}
/** Leave the update critical section, waking any spawn held on whenYtdlpUpdateSettled(). */
export function endYtdlpUpdate(): void {
updating = false
settle?.()
settle = null
}
+170 -23
View File
@@ -1,9 +1,17 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import {
IpcChannels,
type AppUpdateInfo,
type AppUpdateDownload,
type AppUpdateProgress,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus,
type FfmpegVersionResult,
type FfmpegSetupStatus,
type FfmpegSetupProgress,
type FfmpegSetupResult,
type ProbeResult,
type StartDownloadOptions,
type StartDownloadResult,
@@ -13,6 +21,7 @@ import {
type CookiesStatus,
type CookiesLoginResult,
type CommandTemplate,
type MediaProfile,
type CommandPreviewResult,
type ErrorLogEntry,
type BackupExportResult,
@@ -23,33 +32,94 @@ import {
type IndexProgress,
type IndexSourceResult,
type SyncResult,
type ScheduledSyncStatus
type ScheduledSyncStatus,
type TerminalEvent,
type TerminalRunResult,
type TaskbarProgress,
type PersistedQueueItem
} from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
const api = {
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** AeroFetch's own version string (e.g. '0.3.1'). */
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
/** Check the configured Gitea repo for a newer AeroFetch release. */
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
/** Download the latest release's installer to a temp file. */
downloadAppUpdate: (url: string): Promise<AppUpdateDownload> =>
ipcRenderer.invoke(IpcChannels.appUpdateDownload, url),
/** Abort the in-flight installer download (the update card's Cancel button). */
cancelAppUpdate: (): Promise<void> => ipcRenderer.invoke(IpcChannels.appUpdateCancel),
/** Launch a downloaded installer and quit so it can replace the app. */
runAppUpdate: (filePath: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke(IpcChannels.appUpdateRun, filePath),
/** Subscribe to installer download progress. Returns an unsubscribe function. */
onAppUpdateProgress: (cb: (p: AppUpdateProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: AppUpdateProgress): void => cb(p)
ipcRenderer.on(IpcChannels.appUpdateProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
},
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the managed ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
/** Readiness (both binaries present) + versions of the managed ffmpeg/ffprobe. */
getFfmpegSetupStatus: (): Promise<FfmpegSetupStatus> =>
ipcRenderer.invoke(IpcChannels.ffmpegSetupStatus),
/** Download + unpack ffmpeg/ffprobe from the pinned upstream archive (first-run setup). */
downloadFfmpeg: (): Promise<FfmpegSetupResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegSetupDownload),
/** Abort the in-flight ffmpeg archive download (the setup card's Cancel). */
cancelFfmpegDownload: (): Promise<void> => ipcRenderer.invoke(IpcChannels.ffmpegSetupCancel),
/** Offline fallback: pick a folder holding ffmpeg.exe + ffprobe.exe and install from it. */
locateFfmpeg: (): Promise<FfmpegSetupResult> => ipcRenderer.invoke(IpcChannels.ffmpegSetupLocate),
/** Subscribe to ffmpeg setup progress (download %, then extract). Returns an unsubscribe fn. */
onFfmpegSetupProgress: (cb: (p: FfmpegSetupProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: FfmpegSetupProgress): void => cb(p)
ipcRenderer.on(IpcChannels.ffmpegSetupProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.ffmpegSetupProgress, listener)
},
/** Whether the optional aria2c external downloader is present in this build. */
aria2cAvailable: (): Promise<boolean> => ipcRenderer.invoke(IpcChannels.aria2cAvailable),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
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),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
pauseDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadPause, id),
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> =>
@@ -84,6 +154,14 @@ const api = {
removeTemplate: (id: string): Promise<CommandTemplate[]> =>
ipcRenderer.invoke(IpcChannels.templatesRemove, id),
listProfiles: (): Promise<MediaProfile[]> => ipcRenderer.invoke(IpcChannels.profilesList),
saveProfile: (profile: MediaProfile): Promise<MediaProfile[]> =>
ipcRenderer.invoke(IpcChannels.profilesSave, profile),
removeProfile: (id: string): Promise<MediaProfile[]> =>
ipcRenderer.invoke(IpcChannels.profilesRemove, id),
/** Build the exact yt-dlp command line for the given form state, without running it. */
previewCommand: (opts: StartDownloadOptions): Promise<CommandPreviewResult> =>
ipcRenderer.invoke(IpcChannels.commandPreview, opts),
@@ -91,10 +169,24 @@ const api = {
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
/** Subscribe to background yt-dlp auto-update status. Returns an unsubscribe function. */
onYtdlpAutoUpdateStatus: (cb: (s: YtdlpAutoUpdateStatus) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, s: YtdlpAutoUpdateStatus): void => cb(s)
ipcRenderer.on(IpcChannels.ytdlpAutoUpdateStatus, listener)
return () => ipcRenderer.removeListener(IpcChannels.ytdlpAutoUpdateStatus, listener)
},
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
/** 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),
@@ -129,7 +221,7 @@ const api = {
return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener)
},
// --- Media-manager sources (Pinchflat-style; see ROADMAP-PINCHFLAT.md) ---
// --- Media-manager sources (Pinchflat-style) ---
listSources: (): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourcesList),
@@ -141,35 +233,82 @@ const api = {
reindexSource: (id: string): Promise<IndexSourceResult> =>
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
removeSource: (id: string): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
/** Abort the in-flight index/reindex walk (the add-source "Cancel" button). */
cancelIndex: (): Promise<void> => ipcRenderer.invoke(IpcChannels.sourceIndexCancel),
removeSource: (id: string): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourceRemove, id),
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
/** Persist that a media item has finished downloading (drives incremental sync). */
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
/** Persist that a media item has finished downloading (drives incremental sync).
* Named to match the main handler `setMediaItemDownloaded` (L92). */
setMediaItemDownloaded: (id: string, filePath?: string, quality?: string): Promise<MediaItem[]> =>
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath, quality),
/** 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),
/** Point a source at a MediaProfile, or clear it (profileId = null). */
setSourceProfile: (id: string, profileId: string | null): Promise<Source[]> =>
ipcRenderer.invoke(IpcChannels.sourceSetProfile, id, profileId),
/** Read / write the Windows daily-sync scheduled task. */
/** Prune (delete files of) the given media items; returns the source's updated list + count. */
pruneMediaItems: (
sourceId: string,
itemIds: string[]
): Promise<{ items: MediaItem[]; deleted: number }> =>
ipcRenderer.invoke(IpcChannels.sourcePruneItems, sourceId, itemIds),
/** 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. `time` is the 24h 'HH:MM'
* start time (L51); omitted = the default. Re-creating with a new time updates it. */
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
ipcRenderer.invoke(IpcChannels.scheduledSyncGet),
setScheduledSync: (enabled: boolean): Promise<ScheduledSyncStatus> =>
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled),
setScheduledSync: (enabled: boolean, time?: string): Promise<ScheduledSyncStatus> =>
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled, time),
/** Subscribe to live indexing progress. Returns an unsubscribe function. */
onIndexProgress: (cb: (p: IndexProgress) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
ipcRenderer.on(IpcChannels.indexProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
}
},
/** Subscribe to the "run watched-source sync now" nudge a scheduled `--sync` relaunch
* sends when it hits the already-running instance. Returns an unsubscribe function. */
onRunWatchedSync: (cb: () => void): (() => void) => {
const listener = (): void => cb()
ipcRenderer.on(IpcChannels.runWatchedSync, listener)
return () => ipcRenderer.removeListener(IpcChannels.runWatchedSync, listener)
},
// --- Built-in yt-dlp terminal (Phase N) ---
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
cancelTerminal: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.terminalCancel, id),
/** Subscribe to live terminal output. Returns an unsubscribe function. */
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: TerminalEvent): void => cb(ev)
ipcRenderer.on(IpcChannels.terminalOutput, listener)
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
},
/** Reflect overall queue progress on the Windows taskbar. */
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
@@ -185,11 +324,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
}
+235 -20
View File
@@ -1,14 +1,47 @@
import { useState } 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 { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { getTheme, pageBackground } from './theme'
import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './views/DownloadsView'
import { Onboarding } from './views/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings'
import { useSystemTheme } from './store/systemTheme'
import { useSetup } from './store/setup'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
// 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 './lib/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('./views/LibraryView').then((m) => ({ default: m.LibraryView }))
)
const HistoryView = lazy(() =>
import('./views/HistoryView').then((m) => ({ default: m.HistoryView }))
)
const TerminalView = lazy(() =>
import('./views/TerminalView').then((m) => ({ default: m.TerminalView }))
)
const SettingsView = lazy(() =>
import('./views/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: {
@@ -22,8 +55,13 @@ const useStyles = makeStyles({
content: {
flexGrow: 1,
minWidth: 0,
overflowY: 'auto',
padding: '24px 28px'
// L161: <main> no longer scrolls or pads — each screen owns its scroll region
// via the shared `page`/`fill` shells (ui/Screen.tsx), so a page scroll and an
// internal list scroll can never stack. Padding (SPACE.page, UI4) lives on the
// shells.
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}
})
@@ -32,14 +70,170 @@ function App(): React.JSX.Element {
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
const [tab, setTab] = useState<TabValue>('downloads')
const showTerminal = useSettings((s) => s.enableCustomCommands)
const isDark = useResolvedDark()
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+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)
return () => window.removeEventListener('keydown', onKey)
}, [])
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
// directly (not via a selector) so taskbar updates don't re-render the app.
// 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 = queueSummaryOf(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
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))
}, [])
// Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
// other pending items return from the previous session.
useEffect(() => {
useDownloads.getState().hydrate()
}, [])
// Subscribe to ffmpeg setup progress and run the initial readiness check, which drives
// the first-run setup gate below. In an App effect (not at store module load) so it runs
// after window.api is assigned in every context (real bridge and browser-preview mock).
useEffect(() => useSetup.getState().init(), [])
// 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()
}
}, 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(logError('getAppVersion'))
}, [])
const collapsed = useSettings((s) => s.isSidebarCollapsed)
function toggleCollapsed(): void {
updateSettings({ isSidebarCollapsed: !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.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
// Onboarding doubles as the one-time media-tools setup: show it for a fresh user, OR
// whenever ffmpeg/ffprobe are confirmed missing (an existing user upgrading from a
// bundled build). `=== false` (not falsy) so the null "still checking" state never
// flashes the setup screen at a user who actually has ffmpeg.
const ffmpegReady = useSetup((s) => s.ffmpegReady)
const showOnboarding = loaded && (!hasCompletedOnboarding || ffmpegReady === false)
return (
<FluentProvider
@@ -60,19 +254,40 @@ function App(): React.JSX.Element {
<Sidebar
tab={tab}
onTabChange={setTab}
theme={theme}
isDark={isDark}
followingSystem={theme === 'system'}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
onSetTheme={(mode) => updateSettings({ theme: mode })}
version={version}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
showTerminal={showTerminal}
downloadCount={activeCount}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />}
{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)
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;
}
}
@@ -0,0 +1,189 @@
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
label: string
/** short right-aligned hint, e.g. a shortcut or category */
hint?: string
run: () => void
}
const useStyles = makeStyles({
// A plain fixed overlay -- NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: Z.overlay,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: SCRIM
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(RADIUS.card),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: ELEVATION.overlay,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
},
list: {
maxHeight: '50vh',
overflowY: 'auto',
padding: '6px'
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
width: '100%',
padding: '9px 12px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
itemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
itemHint: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200
},
empty: {
padding: '14px 12px',
color: tokens.colorNeutralForeground3
}
})
/**
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
*/
export function CommandPalette({
actions,
onClose
}: {
actions: PaletteAction[]
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const 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()
}, [])
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') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(filtered.length - 1, s + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(0, s - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const a = filtered[sel]
if (a) {
a.run()
onClose()
}
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command palette"
>
<input
ref={inputRef}
className={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} ref={listRef} id={listId} role="listbox" aria-label="Commands">
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
// 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}
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()
onClose()
}}
onMouseEnter={() => setSel(i)}
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</div>
))
)}
</div>
</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,37 +109,59 @@ 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="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Field
label="Format sorting (advanced)"
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}
placeholder="res,fps,vcodec:av01"
onChange={(_, d) => setOpt('formatSort', d.value)}
/>
</Field>
<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 && (
@@ -166,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)' },
@@ -182,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
@@ -197,25 +227,56 @@ 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
label="Split by chapters"
hint="Also save each chapter as its own file. The full-length file is kept too."
>
<Switch
checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)}
/>
</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 && (
@@ -233,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
@@ -1,76 +0,0 @@
import {
Subtitle2,
Body1,
Button,
makeStyles,
tokens
} from '@fluentui/react-components'
import { ArrowDownloadRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '20px'
},
queueHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
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 items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
return (
<div className={styles.root}>
<DownloadBar />
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
{items.length === 0 ? (
<div className={styles.empty}>
<ArrowDownloadRegular fontSize={40} />
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
</div>
) : (
<div className={styles.list}>
{items.map((item) => (
<QueueItem key={item.id} item={item} />
))}
</div>
)}
</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>
)
}
}
+23 -7
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',
@@ -30,16 +31,23 @@ const useStyles = makeStyles({
},
top: { bottom: 'calc(100% + 6px)' },
bottom: { top: 'calc(100% + 6px)' },
right: { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
left: { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
alignStart: { left: 0 },
alignEnd: { right: 0 }
})
interface HintProps {
// `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'
align?: 'start' | 'end'
children: React.ReactNode
}
} & (
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
| { placement: 'left' | 'right'; align?: never }
)
export function Hint({
label,
@@ -56,8 +64,16 @@ export function Hint({
aria-hidden
className={mergeClasses(
styles.bubble,
placement === 'bottom' ? styles.bottom : styles.top,
align === 'end' ? styles.alignEnd : styles.alignStart
placement === 'bottom'
? styles.bottom
: placement === 'right'
? styles.right
: placement === 'left'
? styles.left
: styles.top,
// start/end alignment only applies to vertical (top/bottom) placements
(placement === 'top' || placement === 'bottom') &&
(align === 'end' ? styles.alignEnd : styles.alignStart)
)}
>
{label}
-351
View File
@@ -1,351 +0,0 @@
import { useMemo, useState } from 'react'
import {
Text,
Caption1,
Button,
Checkbox,
Input,
Body1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
OpenRegular,
FolderRegular,
DeleteRegular,
VideoClipRegular,
MusicNote2Regular,
HistoryRegular,
SearchRegular,
ArrowClockwiseRegular,
CheckmarkSquareRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { useSettings } from '../store/settings'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
import { Hint } from './Hint'
import { Select } from './Select'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
header: {
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap'
},
count: {
color: tokens.colorNeutralForeground3,
flexShrink: 0
},
search: {
minWidth: '180px',
flexGrow: 1,
maxWidth: '320px'
},
kindFilter: {
width: '130px',
flexShrink: 0
},
spacer: {
flexGrow: 1
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
row: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
thumb: {
flexShrink: 0,
width: '72px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
body: {
flexGrow: 1,
minWidth: 0,
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'
},
emptyBadge: {
width: '56px',
height: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
fontSize: '26px'
},
emptyHint: {
color: tokens.colorNeutralForeground3
},
noMatches: {
padding: '32px 16px',
textAlign: 'center',
color: tokens.colorNeutralForeground3
}
})
const KIND_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'video', label: 'Video' },
{ 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 isDark = useSettings((s) => s.theme === 'dark')
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
const openFile = useHistory((s) => s.openFile)
const showInFolder = useHistory((s) => s.showInFolder)
const remove = useHistory((s) => s.remove)
const removeMany = useHistory((s) => s.removeMany)
const clear = useHistory((s) => s.clear)
const addFromUrl = useDownloads((s) => s.addFromUrl)
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
const [selectMode, setSelectMode] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return entries.filter((h) => {
if (kindFilter !== 'all' && h.kind !== kindFilter) return false
if (!q) return true
return (
h.title.toLowerCase().includes(q) ||
(h.channel?.toLowerCase().includes(q) ?? false) ||
h.url.toLowerCase().includes(q)
)
})
}, [entries, query, kindFilter])
function redownload(h: HistoryEntry): void {
addFromUrl(h.url, h.kind, h.quality, { title: h.title, channel: h.channel })
}
function toggleSelected(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
const allFilteredSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.id))
function toggleAll(): void {
setSelected(allFilteredSelected ? new Set() : new Set(filtered.map((h) => h.id)))
}
function exitSelectMode(): void {
setSelectMode(false)
setSelected(new Set())
}
function deleteSelected(): void {
removeMany([...selected])
exitSelectMode()
}
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>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
{selectMode ? (
<>
<Caption1 className={styles.count}>{selected.size} selected</Caption1>
<Button size="small" appearance="subtle" onClick={toggleAll}>
{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>
</>
) : (
<>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Input
className={styles.search}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
placeholder="Search title, channel, URL…"
contentBefore={<SearchRegular />}
/>
<Select
className={styles.kindFilter}
aria-label="Filter by type"
value={kindFilter}
options={KIND_FILTER_OPTIONS}
onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
/>
<div className={styles.spacer} />
<Button
size="small"
appearance="subtle"
icon={<CheckmarkSquareRegular />}
onClick={() => setSelectMode(true)}
>
Select
</Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
</>
)}
</div>
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div className={styles.list}>
{filtered.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
aria-label={`Select ${h.title}`}
/>
)}
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
)}
</div>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Re-download" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => redownload(h)}
aria-label="Re-download"
/>
</Hint>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
-568
View File
@@ -1,568 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import {
Subtitle2,
Body1,
Caption1,
Text,
Input,
Button,
Checkbox,
Switch,
Spinner,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
SearchRegular,
ArrowSyncRegular,
ArrowClockwiseRegular,
DeleteRegular,
ArrowDownloadRegular,
ChevronDownRegular,
ChevronRightRegular,
AppsListRegular,
VideoClipMultipleRegular,
AlertRegular,
LibraryRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source } from '@shared/ipc'
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
import { useSettings } from '../store/settings'
import { useDownloads, type DownloadStatus } from '../store/downloads'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending'
const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending',
queued: 'Queued',
downloading: 'Downloading',
completed: 'Downloaded',
error: 'Failed',
canceled: 'Canceled'
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 },
toolbar: {
display: 'flex',
alignItems: 'center',
gap: '14px',
flexWrap: 'wrap',
paddingTop: '2px'
},
toolbarSpacer: { flexGrow: 1 },
switchRow: {
display: 'flex',
alignItems: 'center',
gap: '2px',
color: tokens.colorNeutralForeground2
},
progress: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase200
},
error: { color: tokens.colorPaletteRedForeground1 },
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '56px 16px',
color: tokens.colorNeutralForeground3,
textAlign: 'center'
},
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
card: {
border: `1px solid ${tokens.colorNeutralStroke2}`,
...shorthands.borderRadius(tokens.borderRadiusLarge),
backgroundColor: tokens.colorNeutralBackground1,
overflow: 'hidden'
},
cardHead: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
cursor: 'pointer',
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
srcIcon: {
width: '34px',
height: '34px',
flexShrink: 0,
borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
},
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
watchBadge: {
flexShrink: 0,
fontSize: tokens.fontSizeBase100,
padding: '0 7px',
...shorthands.borderRadius(tokens.borderRadiusCircular),
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
srcSub: { color: tokens.colorNeutralForeground3 },
detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
actionSpacer: { flexGrow: 1 },
group: { display: 'flex', flexDirection: 'column' },
groupHead: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 4px',
color: tokens.colorNeutralForeground2
},
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
rows: { display: 'flex', flexDirection: 'column' },
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '5px 4px 5px 18px'
},
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
rowTitle: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
color: tokens.colorNeutralForeground1
},
rowMeta: { color: tokens.colorNeutralForeground3 },
pill: {
flexShrink: 0,
fontSize: tokens.fontSizeBase200,
padding: '1px 8px',
...shorthands.borderRadius(tokens.borderRadiusCircular),
backgroundColor: tokens.colorNeutralBackground3,
color: tokens.colorNeutralForeground3
},
pillDownloading: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
pillCompleted: {
backgroundColor: tokens.colorPaletteGreenBackground2,
color: tokens.colorPaletteGreenForeground2
},
pillError: {
backgroundColor: tokens.colorPaletteRedBackground2,
color: tokens.colorPaletteRedForeground2
}
})
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
const map = new Map<string, MediaItem[]>()
for (const it of items) {
const arr = map.get(it.playlistTitle) ?? []
arr.push(it)
map.set(it.playlistTitle, arr)
}
const groups = [...map.entries()].map(([title, its]) => ({
title,
items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
}))
groups.sort(
(a, b) =>
(a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
a.title.localeCompare(b.title)
)
return groups
}
function relTime(ms?: number): string {
if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago`
return `${Math.round(hrs / 24)} d ago`
}
export function LibraryView(): React.JSX.Element {
const styles = useStyles()
const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId)
const indexing = useSources((s) => s.indexing)
const selectSource = useSources((s) => s.selectSource)
const indexSource = useSources((s) => s.indexSource)
const reindexSource = useSources((s) => s.reindexSource)
const removeSource = useSources((s) => s.removeSource)
const enqueueItems = useSources((s) => s.enqueueItems)
const setWatched = useSources((s) => s.setWatched)
const syncWatched = useSources((s) => s.syncWatched)
const syncing = useSources((s) => s.syncing)
const downloadItems = useDownloads((s) => s.items)
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
const updateSettings = useSettings((s) => s.update)
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [syncNote, setSyncNote] = useState<string | null>(null)
const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length
// Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => {
if (PREVIEW) return
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
}, [])
async function onCheckNew(): Promise<void> {
setSyncNote(null)
const n = await syncWatched()
setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
}
async function toggleScheduled(next: boolean): Promise<void> {
setScheduled(next) // optimistic
if (PREVIEW) return
const res = await window.api.setScheduledSync(next)
setScheduled(res.enabled)
if (res.error) setError(res.error)
}
// Reset the selection whenever the expanded source changes.
useEffect(() => setSelected(new Set()), [selectedSourceId])
// Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => {
const m = new Map<string, DownloadStatus>()
for (const d of downloadItems) m.set(d.url, d.status)
return m
}, [downloadItems])
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
const groups = useMemo(() => groupByPlaylist(items), [items])
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
async function onIndex(): Promise<void> {
setError(null)
const u = url.trim()
if (!u || indexing.active) return
const res = await indexSource(u)
if (res.ok) setUrl('')
else setError(res.error ?? 'Could not index that link.')
}
function toggle(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
for (const it of groupItems) {
if (on) next.add(it.id)
else next.delete(it.id)
}
return next
})
}
function downloadSelected(): void {
if (!selectedSourceId) return
const chosen = items.filter((it) => selected.has(it.id))
enqueueItems(selectedSourceId, chosen)
setSelected(new Set())
}
function downloadPending(): void {
if (!selectedSourceId) return
enqueueItems(selectedSourceId, pendingItems)
}
function pillClass(status: ItemStatus): string {
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
return styles.pill
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Library</Subtitle2>
<Caption1 className={styles.sub}>
Index a channel or playlist once, then download it into organized folders.
</Caption1>
</div>
<div className={styles.addRow}>
<Input
className={styles.addInput}
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL…"
size="large"
contentBefore={<LibraryRegular />}
disabled={indexing.active}
/>
<Button
size="large"
appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={onIndex}
disabled={!url.trim() || indexing.active}
>
Index
</Button>
</div>
<div className={styles.toolbar}>
<Button
size="small"
appearance="secondary"
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />}
onClick={onCheckNew}
disabled={syncing || watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} />
<span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1>
<Switch
checked={autoDownloadNew}
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
aria-label="Auto-download new uploads"
/>
</span>
<span className={styles.switchRow}>
<Caption1>Daily sync</Caption1>
<Switch
checked={scheduled}
onChange={(_, d) => toggleScheduled(d.checked)}
aria-label="Daily background sync"
/>
</span>
</div>
{indexing.active && (
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Indexing…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
</div>
)}
{error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? (
<div className={styles.empty}>
<LibraryRegular fontSize={40} />
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
</div>
) : (
<div className={styles.list}>
{sources.map((src) => (
<SourceCard
key={src.id}
styles={styles}
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() =>
selectSource(selectedSourceId === src.id ? null : src.id)
}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
<Caption1 className={styles.srcSub}>
{items.length} videos · {pendingItems.length} pending · indexed{' '}
{relTime(src.lastIndexedAt)}
</Caption1>
<div className={styles.actionSpacer} />
{selected.size > 0 ? (
<>
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
Clear
</Button>
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadSelected}
>
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
</Button>
</>
) : (
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadPending}
disabled={pendingItems.length === 0}
>
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
</Button>
)}
<span className={styles.switchRow}>
<Caption1>Watch</Caption1>
<Switch
checked={!!src.watched}
onChange={(_, d) => setWatched(src.id, !!d.checked)}
aria-label={`Watch ${src.title} for new uploads`}
/>
</span>
<Button
size="small"
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() => reindexSource(src.id)}
disabled={indexing.active}
>
Re-index
</Button>
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => removeSource(src.id)}
>
Remove
</Button>
</div>
{groups.map((g) => {
const allOn = g.items.every((it) => selected.has(it.id))
return (
<div key={g.title} className={styles.group}>
<div className={styles.groupHead}>
<AppsListRegular />
<span className={styles.groupTitle}>{g.title}</span>
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1>
<Button
size="small"
appearance="subtle"
onClick={() => toggleGroup(g.items, !allOn)}
>
{allOn ? 'None' : 'All'}
</Button>
</div>
<div className={styles.rows}>
{g.items.map((it) => {
const status = effStatus(it)
return (
<div key={it.id} className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<div className={styles.rowMain}>
<span className={styles.rowTitle}>
{it.playlistIndex}. {it.title}
</span>
{it.durationLabel && (
<Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>
)}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
</div>
)
})}
</div>
</div>
)
})}
</div>
</SourceCard>
))}
</div>
)}
</div>
)
}
/** One collapsible source card (header always shown; children render when expanded). */
function SourceCard({
styles,
source,
expanded,
onToggleExpand,
children
}: {
styles: ReturnType<typeof useStyles>
source: Source
expanded: boolean
onToggleExpand: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<div className={styles.card}>
<div
className={styles.cardHead}
onClick={onToggleExpand}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
aria-expanded={expanded}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<span className={styles.srcTitle}>{source.title}</span>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={styles.srcSub}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
</Caption1>
</div>
</div>
{expanded && children}
</div>
)
}
@@ -0,0 +1,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>
)
}
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { makeStyles, mergeClasses } from '@fluentui/react-components'
import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import type { MediaKind } from '@shared/ipc'
import { useResolvedDark } from '../store/systemTheme'
import { thumbColors } from '../lib/theme'
const useStyles = makeStyles({
box: {
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
img: {
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block'
}
})
/**
* A media preview thumbnail: shows the image at `src` when one is available and
* loads, otherwise a kind icon on a quiet neutral tint (the same palette the
* placeholders used before). The load failure is tracked per-src, so a thumbnail
* that arrives later (e.g. resolved after a probe) is retried rather than left on
* the fallback. The caller sizes the box via `className` (width/height/radius).
*/
export function MediaThumb({
src,
kind,
iconSize = 24,
className
}: {
src?: string
kind: MediaKind
iconSize?: number
className?: string
}): React.JSX.Element {
const styles = useStyles()
const isDark = useResolvedDark()
const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
const [failedSrc, setFailedSrc] = useState<string | null>(null)
const showImg = !!src && failedSrc !== src
// The neutral tint always backs the box, so there's a placeholder behind the
// image while it loads (and the kind icon stays legible when there's no image).
return (
<div
className={mergeClasses(styles.box, className)}
style={{ backgroundColor: colors.bg, color: colors.fg }}
>
{showImg ? (
<img className={styles.img} src={src} alt="" onError={() => setFailedSrc(src ?? null)} />
) : kind === 'audio' ? (
<MusicNote2Regular fontSize={iconSize} />
) : (
<VideoClipRegular fontSize={iconSize} />
)}
</div>
)
}
-154
View File
@@ -1,154 +0,0 @@
import {
Card,
Title2,
Body1,
Caption1,
Field,
Input,
Button,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
FolderRegular,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
RocketRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
const useStyles = makeStyles({
root: {
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflowY: 'auto',
padding: '24px'
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
padding: '32px',
maxWidth: '460px',
width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
brandRow: {
display: 'flex',
alignItems: 'center',
gap: '14px'
},
mark: {
width: '48px',
height: '48px',
flexShrink: 0,
...shorthands.borderRadius(tokens.borderRadiusLarge),
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '26px'
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
tips: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
tip: {
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
},
tipIcon: {
fontSize: '18px',
flexShrink: 0,
marginTop: '2px',
color: tokens.colorCompoundBrandForeground1
}
})
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.'
},
{
icon: <HistoryRegular />,
text: 'Downloads queue with a concurrency cap, and every completed file lands in History.'
},
{
icon: <OptionsRegular />,
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
}
]
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const update = useSettings((s) => s.update)
return (
<div className={styles.root}>
<Card className={styles.card}>
<div className={styles.brandRow}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2>Welcome to AeroFetch</Title2>
</div>
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
<Field label="Download folder">
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
Browse
</Button>
</div>
</Field>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
appearance="primary"
icon={<RocketRegular />}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Get started
</Button>
</Card>
</div>
)
}
@@ -0,0 +1,228 @@
import { useState } from 'react'
import {
Button,
Field,
Input,
Caption1,
Text,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
AddRegular,
EditRegular,
DeleteRegular,
SaveRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { MediaProfile, MediaKind } from '@shared/ipc'
import { QUALITY_OPTIONS } from '../lib/qualityOptions'
import { useProfiles } from '../store/profiles'
import { newId } from '../lib/id'
import { Hint } from './Hint'
import { Select } from './Select'
import { SegmentedControl } from './ui/SegmentedControl'
import { EmptyState } from './ui/EmptyState'
import { SPACE, META_SEP } from './ui/tokens'
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '10px' },
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: `${SPACE.tight} ${SPACE.snug}`,
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
rowBody: { flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column' },
rowName: { fontWeight: tokens.fontWeightSemibold },
rowMeta: { color: tokens.colorNeutralForeground3 },
actions: { display: 'flex', gap: '4px', flexShrink: 0 },
form: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: SPACE.cozy,
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
formRow: { display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap' },
formActions: { display: 'flex', gap: '8px', justifyContent: 'flex-end' }
})
interface Draft {
/** null while adding a brand-new profile */
id: string | null
name: string
kind: MediaKind
quality: string
outputTemplate: string
extraArgs: string
}
const BLANK_DRAFT: Draft = {
id: null,
name: '',
kind: 'video',
quality: 'Best available',
outputTemplate: '',
extraArgs: ''
}
/** One-line summary of a profile's overrides for the list row. */
function summarize(p: MediaProfile): string {
const parts = [p.kind === 'audio' ? 'Audio' : 'Video']
if (p.quality) parts.push(p.quality)
if (p.outputTemplate) parts.push('custom folders')
if (p.extraArgs) parts.push('extra args')
return parts.join(META_SEP)
}
/**
* Inline (no-modal, per the app's composited-overlay avoidance) CRUD list +
* add/edit form for MediaProfile named download presets a Library Source can
* point at. Mirrors TemplateManager; used from SettingsView's "Media profiles"
* card.
*/
export function ProfileManager(): React.JSX.Element {
const styles = useStyles()
const profiles = useProfiles((s) => s.profiles)
const save = useProfiles((s) => s.save)
const remove = useProfiles((s) => s.remove)
const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(p: MediaProfile): void {
setDraft({
id: p.id,
name: p.name,
kind: p.kind ?? 'video',
quality: p.quality ?? 'Best available',
outputTemplate: p.outputTemplate ?? '',
extraArgs: p.extraArgs ?? ''
})
}
function commit(): void {
if (!draft) return
const name = draft.name.trim()
if (!name) return
const outputTemplate = draft.outputTemplate.trim()
const extraArgs = draft.extraArgs.trim()
save({
id: draft.id ?? newId('prof'),
name,
kind: draft.kind,
quality: draft.quality,
...(outputTemplate ? { outputTemplate } : {}),
...(extraArgs ? { extraArgs } : {})
})
setDraft(null)
}
return (
<div className={styles.root}>
{profiles.length === 0 && !draft && <EmptyState compact message="No media profiles yet." />}
{profiles.map((p) => (
<div key={p.id} className={styles.row}>
<div className={styles.rowBody}>
<Text className={styles.rowName}>{p.name}</Text>
<Caption1 className={styles.rowMeta}>{summarize(p)}</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Edit" placement="top" align="end">
<Button
appearance="subtle"
icon={<EditRegular />}
onClick={() => startEdit(p)}
aria-label="Edit profile"
/>
</Hint>
<Hint label="Delete" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(p.id)}
aria-label="Delete profile"
/>
</Hint>
</div>
</div>
))}
{draft ? (
<div className={styles.form}>
<Input
value={draft.name}
placeholder="Profile name, e.g. Archive (1080p)"
onChange={(_, d) => setDraft({ ...draft, name: d.value })}
/>
<div className={styles.formRow}>
<Field label="Format">
<SegmentedControl<MediaKind>
value={draft.kind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={(kind) =>
setDraft({ ...draft, kind, quality: QUALITY_OPTIONS[kind][0] ?? draft.quality })
}
ariaLabel="Profile format"
/>
</Field>
<Field label="Quality">
<Select
value={draft.quality}
options={QUALITY_OPTIONS[draft.kind].map((q) => ({ value: q, label: q }))}
onChange={(quality) => setDraft({ ...draft, quality })}
/>
</Field>
</div>
<Field
label="Collection folder layout (optional)"
hint="Raw yt-dlp -o template for this profile's sources. Blank = the global default."
>
<Input
value={draft.outputTemplate}
placeholder="%(uploader)s/%(playlist)s/%(title)s.%(ext)s"
onChange={(_, d) => setDraft({ ...draft, outputTemplate: d.value })}
/>
</Field>
<Field label="Extra yt-dlp args (optional)">
<Input
value={draft.extraArgs}
placeholder="--write-thumbnail --no-mtime"
onChange={(_, d) => setDraft({ ...draft, extraArgs: d.value })}
/>
</Field>
<div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel
</Button>
<Button
appearance="primary"
icon={<SaveRegular />}
onClick={commit}
disabled={!draft.name.trim()}
>
Save
</Button>
</div>
</div>
) : (
<Button
appearance="subtle"
icon={<AddRegular />}
onClick={() => setDraft({ ...BLANK_DRAFT })}
>
Add profile
</Button>
)}
</div>
)
}
+202 -64
View File
@@ -1,11 +1,12 @@
import { memo } from 'react'
import {
Text,
Caption1,
Button,
ProgressBar,
Badge,
Spinner,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -15,34 +16,47 @@ import {
OpenRegular,
FolderRegular,
ArrowClockwiseRegular,
VideoClipRegular,
MusicNote2Regular,
ArrowDownloadRegular,
ArrowUpRegular,
BookmarkRegular,
PauseRegular,
PlayRegular,
CheckmarkCircleFilled,
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useSettings } from '../store/settings'
import { thumbColors } from '../theme'
import { useShallow } from 'zustand/react/shallow'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../lib/thumb'
import { fmtSchedule } from '../lib/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 '../lib/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,
@@ -56,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
@@ -84,105 +89,238 @@ 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' },
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)}%`
}
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
export const QueueItem = memo(function QueueItem({
item
}: {
item: DownloadItem
}): React.JSX.Element {
const styles = useStyles()
const isDark = useSettings((s) => s.theme === 'dark')
const thumb = thumbColors[isDark ? 'dark' : 'light'][item.kind === 'audio' ? 'audio' : 'video']
const cancel = useDownloads((s) => s.cancel)
const 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={styles.thumb} style={{ backgroundColor: thumb.bg, color: thumb.fg }}>
{item.kind === 'audio' ? (
<MusicNote2Regular fontSize={28} />
) : (
<VideoClipRegular fontSize={28} />
)}
</div>
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
kind={item.kind}
iconSize={28}
/>
<div className={styles.body}>
<div className={styles.titleRow}>
{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"
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'}
</Caption1>
)}
{item.status === 'error' && item.error && (
<Caption1 className={styles.error}>{item.error}</Caption1>
)}
</div>
<div className={styles.actions}>
{item.status === 'downloading' && (
<Hint label="Pause" placement="top" align="end">
<Button
appearance="subtle"
icon={<PauseRegular />}
onClick={() => pause(item.id)}
aria-label="Pause"
/>
</Hint>
)}
{item.status === 'queued' && (
<>
<Hint label="Download next" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowUpRegular />}
onClick={() => prioritize(item.id)}
aria-label="Download next"
/>
</Hint>
<Hint label="Save for later" placement="top" align="end">
<Button
appearance="subtle"
icon={<BookmarkRegular />}
onClick={() => saveForLater(item.id)}
aria-label="Save for later"
/>
</Hint>
</>
)}
{item.status === 'paused' && (
<Hint label="Resume" placement="top" align="end">
<Button
appearance="subtle"
icon={<PlayRegular />}
onClick={() => resume(item.id)}
aria-label="Resume"
/>
</Hint>
)}
{item.status === 'saved' && (
<Hint label="Add to queue" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowDownloadRegular />}
onClick={() => queueNow(item.id)}
aria-label="Add to queue"
/>
</Hint>
)}
{active && (
<Hint label="Cancel" placement="top" align="end">
<Button
@@ -239,4 +377,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
</div>
</div>
)
}
})
+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}>
@@ -1,784 +0,0 @@
import { useEffect, useState } from 'react'
import {
Field,
Input,
SpinButton,
Switch,
Button,
Card,
Subtitle2,
Caption1,
Spinner,
Text,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
FolderRegular,
ArrowDownloadRegular,
DocumentRegular,
OptionsRegular,
InfoRegular,
GlobeRegular,
CookiesRegular,
CodeRegular,
ArrowClockwiseRegular,
DocumentArrowDownRegular,
DocumentArrowUpRegular,
BugRegular,
CopyRegular,
DeleteRegular,
PaintBucketRegular,
AccessibilityRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type MediaKind,
type CookieSource,
type CookieBrowser,
type CookiesStatus,
type BackupExportResult,
type BackupImportResult,
type ThemeMode,
type AccentColor
} from '@shared/ipc'
import { useSettings } from '../store/settings'
import { useSystemTheme } from '../store/systemTheme'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { useTemplates } from '../store/templates'
import { useErrorLog } from '../store/errorlog'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { TemplateManager } from './TemplateManager'
import { ACCENT_OPTIONS } from '../theme'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
maxWidth: '640px'
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '20px',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
sectionHeader: {
display: 'flex',
alignItems: 'center',
gap: '10px'
},
sectionIcon: {
fontSize: '20px',
color: tokens.colorCompoundBrandForeground1
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
hint: {
color: tokens.colorNeutralForeground3
},
swatchRow: {
display: 'flex',
gap: '10px'
},
swatch: {
boxSizing: 'border-box',
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius('50%'),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer'
},
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'
},
errorRowText: {
color: tokens.colorPaletteRedForeground1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
})
// Combined "Type — Quality" options for the default-format dropdown.
const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video — ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
]
const THEME_MODE_OPTIONS = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' }
]
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[0].toUpperCase() + b.slice(1)
}))
const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' },
{ value: 'nightly', label: 'Nightly' }
]
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
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 filenameTemplate = useSettings((s) => s.filenameTemplate)
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c)
const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
const errorEntries = useErrorLog((s) => s.entries)
const clearErrorLog = useErrorLog((s) => s.clear)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
const formatValue = `${defaultKind}|${formatQuality}`
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
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)
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
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)
}
}
function copyErrorReport(): void {
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 || 'No errors logged.').catch(() => {})
}
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok) setCookiesStatus(await window.api.cookiesStatus())
else setLoginError(result.error ?? 'Sign-in failed.')
} 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 })
}
function onFormatSelect(value: string): void {
const [kind, quality] = value.split('|') as [MediaKind, string]
update(
kind === 'audio'
? { defaultKind: 'audio', defaultAudioQuality: quality }
: { defaultKind: 'video', defaultVideoQuality: quality }
)
}
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(updateChannel)
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 (
<div className={styles.root}>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2>Downloads</Subtitle2>
</div>
<Field label="Download folder">
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
Browse
</Button>
</div>
</Field>
<Field label="Default format">
<Select
aria-label="Default format"
value={formatValue}
options={FORMAT_OPTIONS}
onChange={onFormatSelect}
/>
</Field>
<Field
label="Maximum simultaneous downloads"
hint="How many downloads run at once. 23 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 })}
label={clipboardWatch ? 'On' : 'Off'}
/>
</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 })}
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2>Appearance</Subtitle2>
</div>
<Field label="Theme">
<Select
aria-label="Theme"
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
/>
</Field>
<Field label="Accent color">
<div className={styles.swatchRow}>
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={mergeClasses(
styles.swatch,
accentColor === opt.value && styles.swatchActive
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-pressed={accentColor === opt.value}
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>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2>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}
onChange={(o) => update({ downloadOptions: o })}
/>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2>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
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. Requires aria2c.exe in resources/bin (see the README there) — falls back to yt-dlp's downloader if it's missing."
>
<Switch
checked={useAria2c}
onChange={(_, d) => update({ useAria2c: d.checked })}
label={useAria2c ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2>Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a
logged-in session. Supply cookies so yt-dlp can act like one.
</Caption1>
<Field label="Cookie source">
<Select
aria-label="Cookie source"
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 it first if yt-dlp can't open a locked cookie database."
>
<Select
aria-label="Browser"
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 style={{ color: tokens.colorPaletteRedForeground1 }}>{loginError}</Caption1>
)}
</>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} />
<Subtitle2>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 })}
label={customCommandEnabled ? 'On' : 'Off'}
/>
</Field>
{customCommandEnabled && (
<Field label="Default template">
<Select
aria-label="Default template"
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>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
<Subtitle2>Filenames</Subtitle2>
</div>
<Field
label="Filename template"
hint="yt-dlp output template. Tokens like %(title)s and %(ext)s are filled in per video."
>
<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 })}
label={restrictFilenames ? 'On' : 'Off'}
/>
</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 })}
label={downloadArchive ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2>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.
</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 style={{ color: tokens.colorPaletteRedForeground1 }}>
{exportResult.error}
</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{importResult.error}
</Caption1>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2>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>
<Button icon={<DeleteRegular />} onClick={clearErrorLog} disabled={errorEntries.length === 0}>
Clear log
</Button>
</div>
{errorEntries.length === 0 ? (
<Caption1 className={styles.hint}>No errors logged.</Caption1>
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id + String(e.occurredAt)} 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={styles.errorRowText}>{e.error}</Caption1>
</div>
))}
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg.
</Caption1>
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
{version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
{version && !version.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{version.error}
</Caption1>
)}
<Field label="Update channel" hint="Nightly tracks yt-dlp's daily build; stable is the tagged release.">
<Select
aria-label="Update channel"
value={updateChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)}
/>
</Field>
<div className={styles.folderRow}>
<Button icon={<ArrowClockwiseRegular />} onClick={runUpdate} disabled={updating}>
{updating ? 'Updating…' : 'Update yt-dlp'}
</Button>
{updating && <Spinner size="tiny" />}
</div>
{updateResult?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace, whiteSpace: 'pre-wrap' }}>
{updateResult.output || 'yt-dlp is already up to date.'}
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{updateResult.error}
</Caption1>
)}
</Card>
</div>
)
}
+178 -52
View File
@@ -1,6 +1,6 @@
import {
import {
Caption1,
Switch,
CounterBadge,
makeStyles,
mergeClasses,
tokens,
@@ -11,12 +11,23 @@ import {
ArrowDownloadRegular,
HistoryRegular,
LibraryRegular,
WindowConsoleRegular,
SettingsRegular,
WeatherMoonRegular,
WeatherSunnyRegular
WeatherSunnyRegular,
DesktopRegular,
PanelLeftContractRegular,
PanelLeftExpandRegular
} from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
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' | 'settings'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
const useStyles = makeStyles({
root: {
@@ -27,13 +38,31 @@ const useStyles = makeStyles({
gap: '4px',
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
// The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
transition: `width ${MOTION.duration} ${MOTION.curve}`
},
rootCollapsed: {
width: '60px',
alignItems: 'center'
},
topBar: {
display: 'flex',
justifyContent: 'flex-end',
paddingBottom: '2px'
},
topBarCollapsed: {
justifyContent: 'center'
},
brand: {
display: 'flex',
alignItems: 'center',
gap: '11px',
padding: '6px 10px 14px'
padding: '0 10px 14px'
},
brandCollapsed: {
padding: '0 0 12px',
justifyContent: 'center'
},
mark: {
width: '36px',
@@ -45,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',
@@ -58,15 +88,15 @@ const useStyles = makeStyles({
lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1
},
caption: {
color: tokens.colorNeutralForeground3
},
nav: {
display: 'flex',
flexDirection: 'column',
gap: '3px'
gap: '3px',
alignSelf: 'stretch'
},
navItem: {
// relative so the collapsed count badge can pin to the icon's corner.
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '11px',
@@ -84,32 +114,39 @@ 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'
},
navItemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
fontWeight: tokens.fontWeightSemibold,
// The active-nav rail shows in BOTH sidebar states (UI21) — collapsed used to
// rely on tint alone. It hugs the row's left edge, clear of the collapsed
// count badge pinned at the icon's top-right.
boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}`,
':hover': {
backgroundColor: tokens.colorBrandBackground2
}
},
navIcon: {
fontSize: '18px',
flexShrink: 0
fontSize: `${ICON.control}px`,
flexShrink: 0,
display: 'flex'
},
spacer: {
flexGrow: 1
},
themeRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
color: tokens.colorNeutralForeground3
},
themeLabel: {
display: 'flex',
alignItems: 'center',
gap: '9px'
}
})
@@ -117,70 +154,159 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
]
const THEMES: { value: ThemeMode; label: string; icon: React.JSX.Element }[] = [
{ value: 'light', label: 'Light', icon: <WeatherSunnyRegular /> },
{ value: 'dark', label: 'Dark', icon: <WeatherMoonRegular /> },
{ value: 'system', label: 'Auto', icon: <DesktopRegular /> }
]
interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
/** the current theme preference: explicit light/dark, or 'system' to follow the OS */
theme: ThemeMode
/** whether the resolved theme is currently dark (drives the collapsed toggle icon) */
isDark: boolean
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
followingSystem: boolean
onToggleTheme: () => void
onSetTheme: (mode: ThemeMode) => void
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
version: string
collapsed: boolean
onToggleCollapsed: () => void
/** 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({
tab,
onTabChange,
theme,
isDark,
followingSystem,
onToggleTheme
onSetTheme,
version,
collapsed,
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 {
const next = order[(order.indexOf(theme) + 1) % order.length]
if (next) onSetTheme(next)
}
const themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={ICON.control} />
) : isDark ? (
<WeatherMoonRegular fontSize={ICON.control} />
) : (
<WeatherSunnyRegular fontSize={ICON.control} />
)
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
return (
<nav className={styles.root}>
<div className={styles.brand}>
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<IconButton
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
/>
</Hint>
</div>
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
</div>
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
{/* 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
return (
const btn = (
<button
key={n.value}
type="button"
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
style={
active
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined
}
className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive,
focus.focusRing
)}
onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined}
aria-label={
n.value === 'downloads' && downloadCount > 0
? `${n.label}, ${downloadCount} active`
: n.label
}
>
<span className={styles.navIcon}>{n.icon}</span>
{n.label}
{!collapsed && n.label}
{n.value === 'downloads' && downloadCount > 0 && (
<CounterBadge
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
count={downloadCount}
size="small"
color="brand"
aria-hidden
/>
)}
</button>
)
return collapsed ? (
<Hint key={n.value} label={n.label} placement="right">
{btn}
</Hint>
) : (
btn
)
})}
</div>
<div className={styles.spacer} />
<div className={styles.themeRow}>
<span className={styles.themeLabel}>
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
</span>
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
</div>
{collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<IconButton
icon={themeIcon}
onClick={cycleTheme}
aria-label={`Change theme (currently ${themeLabel})`}
/>
</Hint>
) : (
<SegmentedControl<ThemeMode>
fitted
value={theme}
options={THEMES}
onChange={onSetTheme}
ariaLabel="Theme"
/>
)}
</nav>
)
}
@@ -0,0 +1,131 @@
import { useState } from 'react'
import {
Button,
Field,
SpinButton,
Caption1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { DeleteRegular, ArrowUpRegular } from '@fluentui/react-icons'
import { useSources } from '../store/sources'
import { toast } from '../store/toasts'
import { SPACE } from './ui/tokens'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: SPACE.cozy,
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
heading: { fontWeight: tokens.fontWeightSemibold },
controls: { display: 'flex', alignItems: 'flex-end', gap: '12px', flexWrap: 'wrap' },
spacer: { flexGrow: 1 },
muted: { color: tokens.colorNeutralForeground3 },
narrow: { width: '120px' }
})
/**
* Retention / quality-upgrade panel for one Library source (PINCHFLAT stretch).
* Two opt-in maintenance actions:
*
* - Prune: keep only the N most-recent (and/or last-D-days) downloaded files;
* delete the older ones' files (the download archive stops them silently
* re-downloading). Destructive, so it shows a dry-run count and a two-step
* confirm before any file is deleted.
* - Upgrade: re-download items that were fetched below the source's current
* target quality (from its profile or the global default).
*
* The policy inputs are ephemeral (per-open) this is an on-demand tidy, not a
* persisted schedule, keeping retention to a desktop-app-sized scope.
*/
export function SourceRetention({ sourceId }: { sourceId: string }): React.JSX.Element {
const styles = useStyles()
const prunePreview = useSources((s) => s.prunePreview)
const pruneItems = useSources((s) => s.pruneItems)
const upgradePreview = useSources((s) => s.upgradePreview)
const upgradeItems = useSources((s) => s.upgradeItems)
const [maxCount, setMaxCount] = useState(25)
const [confirmIds, setConfirmIds] = useState<string[] | null>(null)
function onPrune(): void {
const candidates = prunePreview(sourceId, { maxCount })
if (candidates.length === 0) {
toast(`Nothing to prune — the source has ${maxCount} or fewer downloaded files.`, 'info')
return
}
setConfirmIds(candidates.map((c) => c.id))
}
async function onConfirmPrune(): Promise<void> {
if (!confirmIds) return
const deleted = await pruneItems(sourceId, confirmIds)
toast(`Pruned ${deleted} file${deleted === 1 ? '' : 's'}.`, 'info')
setConfirmIds(null)
}
function onUpgrade(): void {
const candidates = upgradePreview(sourceId)
if (candidates.length === 0) {
toast('Nothing to upgrade — every downloaded video is already at the target quality.', 'info')
return
}
const n = upgradeItems(sourceId, candidates)
toast(`Re-queued ${n} video${n === 1 ? '' : 's'} to upgrade quality.`, 'info')
}
return (
<div className={styles.root}>
<Caption1 className={styles.heading}>Retention &amp; quality</Caption1>
{confirmIds ? (
<div className={styles.controls}>
<Caption1 className={styles.muted}>
Delete {confirmIds.length} older downloaded file{confirmIds.length === 1 ? '' : 's'}?
The videos stay indexed and won&apos;t re-download.
</Caption1>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={onConfirmPrune}
>
Delete {confirmIds.length}
</Button>
<Button size="small" appearance="subtle" onClick={() => setConfirmIds(null)}>
Cancel
</Button>
</div>
) : (
<div className={styles.controls}>
<Field label="Keep newest">
<SpinButton
className={styles.narrow}
value={maxCount}
min={1}
max={9999}
onChange={(_, d) => {
const v = d.value ?? Number(d.displayValue)
if (typeof v === 'number' && Number.isFinite(v))
setMaxCount(Math.max(1, Math.round(v)))
}}
/>
</Field>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={onPrune}>
Prune older files
</Button>
<div className={styles.spacer} />
<Button size="small" appearance="subtle" icon={<ArrowUpRegular />} onClick={onUpgrade}>
Upgrade below-target
</Button>
</div>
)}
</div>
)
}
+54 -16
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import {
Button,
Field,
Input,
Textarea,
Caption1,
@@ -9,10 +10,19 @@ 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 '../lib/id'
import { Hint } from './Hint'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
const useStyles = makeStyles({
root: {
@@ -24,7 +34,8 @@ const useStyles = makeStyles({
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
// Control-tier padding (UI3): tight vertical, snug horizontal.
padding: `${SPACE.tight} ${SPACE.snug}`,
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
@@ -54,7 +65,8 @@ const useStyles = makeStyles({
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
// List-row padding tier (UI3): cozy on both axes.
padding: SPACE.cozy,
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
@@ -63,9 +75,6 @@ const useStyles = makeStyles({
display: 'flex',
gap: '8px',
justifyContent: 'flex-end'
},
empty: {
color: tokens.colorNeutralForeground3
}
})
@@ -74,12 +83,10 @@ interface Draft {
id: string | null
name: string
args: string
/** optional regex; auto-applies the template to matching URLs */
urlPattern: string
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
/**
* Inline (no-modal see the Hint/Select comments re: composited overlays
@@ -95,21 +102,38 @@ export function TemplateManager(): React.JSX.Element {
const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args })
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
}
function 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
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
if (!name || patternError()) return
const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId('tpl'),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
})
setDraft(null)
}
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) => (
@@ -154,6 +178,16 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: 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
@@ -169,7 +203,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>
)}
@@ -0,0 +1,77 @@
import { useRef, type CSSProperties, type ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
/**
* Windowed list: renders only the rows near the viewport, so a list thousands of
* items long stays light one DOM subtree per visible row, not per item. This
* keeps the Downloads queue and a big channel's Library list responsive when a
* source has 1000s of videos.
*
* It owns its own scroll container, so size that container via `style`/`className`
* (e.g. `flex: 1` + `minHeight: 0`, or a `maxHeight`). Owning the scroller means
* the virtualizer anchors at scroll offset 0 and never has to track where the
* list sits within the page robust even though the app shares one outer
* scroll area with content above each list.
*
* Row heights are measured (measureElement), so variable-height rows are fine;
* `estimateSize` only needs to be a rough first guess before measurement.
*/
export function VirtualList<T>({
items,
estimateSize,
getKey,
renderItem,
overscan = 8,
gap = 0,
className,
style
}: {
items: T[]
estimateSize: (index: number) => number
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => ReactNode
overscan?: number
gap?: number
className?: string
style?: CSSProperties
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize,
overscan,
gap,
getItemKey: (index) => {
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) => {
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,171 @@
import { Button, Checkbox, Caption1, Spinner, mergeClasses } from '@fluentui/react-components'
import {
AppsListRegular,
VideoClipRegular,
MusicNote2Regular,
OptionsRegular
} from '@fluentui/react-icons'
import { BEST_FORMAT_ID, type FormatOption, type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads'
import { type ChosenFormat } from '../../store/downloadTypes'
import { Hint } from '../Hint'
import { Select } from '../Select'
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
// per-item exact-format picker (video rows only)
formatExpanded: Set<number>
itemFormatOptions: Record<number, FormatOption[]>
itemFormat: (index: number) => ChosenFormat | undefined
formatProbing: Set<number>
formatProbeError: Record<number, string>
onToggleFormat: (index: number) => void
onSetItemFormat: (index: number, formatId: string) => void
}
export function PlaylistPanel({
playlist,
selected,
allSelected,
onToggleAll,
onSetAllKinds,
onToggleEntry,
effKind,
onToggleItemKind,
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
onToggleFormat,
onSetItemFormat
}: 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) => {
const isVideo = effKind(e.index) === 'video'
const chosen = itemFormat(e.index)
const expanded = formatExpanded.has(e.index)
const probing = formatProbing.has(e.index)
const error = formatProbeError[e.index]
const options = itemFormatOptions[e.index]
return (
<div key={e.index} className={styles.plItemGroup}>
<div 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 || chosen) && (
<Caption1 className={text.muted}>
{[e.durationLabel, e.uploader, chosen?.label]
.filter(Boolean)
.join(META_SEP)}
</Caption1>
)}
</span>
}
/>
{isVideo && (
<Hint
label={expanded ? 'Hide format' : 'Choose exact format'}
placement="top"
align="end"
>
<IconButton
size="sm"
style={{ flexShrink: 0 }}
className={chosen ? styles.plFormatBtnActive : undefined}
aria-pressed={expanded}
icon={<OptionsRegular />}
onClick={() => onToggleFormat(e.index)}
aria-label={`Choose format for ${e.title}`}
/>
</Hint>
)}
<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>
{isVideo && expanded && (
<div className={styles.plFormatPanel}>
{probing ? (
<Caption1 className={styles.plFormatStatus}>
<Spinner size="tiny" /> Fetching formats
</Caption1>
) : error ? (
<Caption1 className={styles.plFormatError}>{error}</Caption1>
) : options && options.length > 0 ? (
<Select
className={styles.plFormatSelect}
aria-label={`Format for ${e.title}`}
value={chosen?.id ?? BEST_FORMAT_ID}
options={options.map((o) => ({ value: o.id, label: o.label }))}
onChange={(v) => onSetItemFormat(e.index, v)}
/>
) : (
<Caption1 className={text.muted}>No selectable formats.</Caption1>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,45 @@
import { type PlaylistEntry } from '@shared/ipc'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { type MediaKind } from '../../store/downloads'
import { type AddEntry, type ChosenFormat } from '../../store/downloadTypes'
/** The download-bar state a playlist add reads to build its per-entry queue rows. */
export interface PlaylistAddContext {
/** the bar's global kind (an entry with no override inherits this) */
barKind: MediaKind
/** the bar's global quality preset (used when an entry's kind matches barKind) */
barQuality: string
/** effective kind for an entry index: its override, else barKind */
effKind: (index: number) => MediaKind
/** exact per-item format the user probed + picked (video rows only) */
itemFormats: Record<number, ChosenFormat>
}
/**
* Map selected playlist entries to queue rows for a single `addMany` batch.
* Each entry uses its own kind override; a video entry the user gave an exact
* probed format downloads that format, otherwise quality falls back to the bar's
* preset (when the kind matches) or that kind's default. Kept pure so the mapping
* is unit-testable apart from the React hook.
*/
export function buildPlaylistAddEntries(
chosen: PlaylistEntry[],
ctx: PlaylistAddContext
): AddEntry[] {
return chosen.map((e) => {
const k = ctx.effKind(e.index)
// A per-item exact format only makes sense for a video row (audio is a preset).
const fmt = k === 'video' ? ctx.itemFormats[e.index] : undefined
return {
url: e.url,
kind: k,
quality: fmt ? fmt.label : k === ctx.barKind ? ctx.barQuality : QUALITY_OPTIONS[k][0],
opts: {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
...(fmt ? { format: fmt } : {})
}
}
})
}
@@ -0,0 +1,225 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { THUMB_LG } from '../../lib/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'
},
// The "fetched" state tag beside the Quality label once real formats load
// (UX18) — the label itself stays stable instead of morphing.
qualityFetched: {
color: tokens.colorBrandForeground2
},
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
},
// Column wrapper per entry: the checkbox/toggle row plus its (optional) format panel.
plItemGroup: {
display: 'flex',
flexDirection: 'column'
},
// The lazily-probed per-item format picker, indented under its entry row.
plFormatPanel: {
display: 'flex',
alignItems: 'center',
gap: '8px',
// Line up under the entry title (past the checkbox), leave room below.
padding: '0 0 6px 28px'
},
plFormatSelect: {
maxWidth: '260px'
},
// Brand-tint the per-item format button once an exact format is chosen for that row.
plFormatBtnActive: {
color: tokens.colorBrandForeground1
},
plFormatStatus: {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: tokens.colorNeutralForeground3
},
plFormatError: {
color: tokens.colorPaletteRedForeground1
},
// --- 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}`
}
})

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