Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e10b100d | |||
| 6db27b946f | |||
| eb53de2ea5 | |||
| cb25262a2d | |||
| 27cae28e12 | |||
| 6cb9c5ca47 | |||
| 5de4b40c1d | |||
| 1be2708d15 | |||
| 037ea2da32 | |||
| 48ce591702 | |||
| e29374dcfa | |||
| ff9077989f | |||
| 6cf6c0ead6 | |||
| e7fa602d73 | |||
| 2988404dcf | |||
| 6812939848 | |||
| 3623418fe3 | |||
| d8df5ea7c8 | |||
| 15c2229539 | |||
| 4c2b54a599 | |||
| 5c88d9c8e0 | |||
| 61d5261e33 | |||
| a8dc89d6fc | |||
| 0956bd536a | |||
| 15cc96dd10 | |||
| ed1daf8bef | |||
| a02953f6d1 | |||
| 42b7b2e01f | |||
| 72feaa62f8 | |||
| 9134e7d216 | |||
| f45acb645b | |||
| 8f6b2a761b | |||
| 6abed2064d | |||
| 134c6a167c | |||
| 545cfeed5e | |||
| 384870ee4a | |||
| ea52735b12 | |||
| e9d41b00ea | |||
| 34fbf36610 | |||
| d1e3168449 | |||
| 3b0d668f6c | |||
| f54d9a1965 | |||
| fc2db1e1b6 | |||
| a5885739d2 | |||
| 93f0091104 | |||
| cdb6eea437 | |||
| 747cecd1e0 | |||
| 17d42ed4fe | |||
| dbbb0c70f9 | |||
| 71b88e2304 | |||
| 2ef19ead71 | |||
| 96800d5a17 | |||
| e19f988c72 | |||
| b3e5e4f309 | |||
| a2c8992650 | |||
| 4d1c33a081 | |||
| 0f784847a3 | |||
| 71f0b2b68f | |||
| d131ee67d5 | |||
| 661a4e7572 | |||
| 814ecac287 | |||
| 3d4e574916 | |||
| bd7ba8726e | |||
| e6b4f343e0 | |||
| cc4fb132e4 | |||
| ab825488b8 | |||
| 64af608596 | |||
| 809c99d5bb | |||
| 63f0a40f0e | |||
| 0a681f864f | |||
| 63687aaec3 | |||
| abd9f688b9 | |||
| 81dd0ce742 | |||
| 701c73a889 | |||
| 3baba3b7bc | |||
| 66eaaac8fc | |||
| 519e522917 | |||
| 6bd29efd23 | |||
| 4dfb3c9867 | |||
| 6a9856b590 | |||
| 29ce270d46 | |||
| 4aec32e3c2 | |||
| 36699531cf | |||
| 9e0064aab2 | |||
| e0afaf0fdf | |||
| ae12e7c9cf | |||
| e4e175736f | |||
| ac99fd86a5 | |||
| 4492eee054 | |||
| 3d09174c16 | |||
| d050e48af6 | |||
| 8267da50dc | |||
| e0465bc45e | |||
| cfa76314d7 | |||
| 7e3e5af52d | |||
| 6c19899f75 | |||
| 0f572488fc | |||
| 15d64b7ba6 | |||
| 6ee4dbe78e | |||
| 8cbfc90f5d | |||
| af6c4d3c67 | |||
| c55e131fb0 | |||
| eb94e84707 | |||
| 0d2000f307 | |||
| d7b5737806 | |||
| ab085f2bfe | |||
| 9abd2c4441 | |||
| 6c7caa269c | |||
| 5241abbea9 | |||
| fa4b41e0a5 | |||
| 036ef74362 | |||
| 04109d8220 | |||
| 53aaed5fef | |||
| 2f59a6ce63 | |||
| a4fd0feb6b | |||
| 1f2465576b | |||
| 3ae4a5357c | |||
| b035a88873 | |||
| 6a8452d6c3 | |||
| 3df32d0505 | |||
| c038b94c6f | |||
| 67c0cd8bf4 | |||
| 4e3b141d6d | |||
| 63a327a31c | |||
| 94c5723246 | |||
| 3086ac469f | |||
| 910cf23e61 | |||
| ba208de98b | |||
| 9d816f0c86 | |||
| 72462cab1e | |||
| 69d68e1854 | |||
| 8ab85da67e | |||
| 1376c2dee8 | |||
| a6a8c5f578 | |||
| d112bc4878 | |||
| 5a9f2de390 | |||
| f167c02946 | |||
| e2b33603c6 |
+2
-2
@@ -4,8 +4,8 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "ui"],
|
||||
"port": 5174
|
||||
"runtimeArgs": ["run", "ui", "--", "--port", "4321", "--strictPort"],
|
||||
"port": 4321
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,3 +10,9 @@ dist
|
||||
|
||||
# Secrets — never commit
|
||||
.gitea-token
|
||||
# Retired: no longer read by the build (baked update token removed), but the
|
||||
# local file still holds a live token until it's revoked — keep it ignored.
|
||||
.update-token
|
||||
|
||||
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
|
||||
.vscode/settings.json
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "ui"],
|
||||
"port": 5174
|
||||
}
|
||||
]
|
||||
}
|
||||
-342
@@ -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).
|
||||
@@ -1,238 +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 A–E, 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).
|
||||
|
||||
> **See also:** [ROADMAP.md](ROADMAP.md) → **Post-parity** — YTDLnis-derived editing features,
|
||||
> Windows-native polish, and reliability work, Phases L onward.
|
||||
|
||||
---
|
||||
|
||||
## Implementation status — Phases F–K 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.
|
||||
|
||||
- [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
|
||||
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
|
||||
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
|
||||
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
|
||||
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
|
||||
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
|
||||
playlist's video list on demand rather than all up front.
|
||||
- [x] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
|
||||
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
|
||||
```ts
|
||||
interface Source {
|
||||
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
|
||||
}
|
||||
```
|
||||
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
|
||||
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
|
||||
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
|
||||
no real playlist.
|
||||
- [x] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
|
||||
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
|
||||
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
|
||||
|
||||
## Phase G — Folder organization & output-path templates ✅ COMPLETE
|
||||
|
||||
Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`.
|
||||
|
||||
- [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
|
||||
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
|
||||
joined subpath) so each `MediaItem` downloads into
|
||||
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
|
||||
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
|
||||
empty under the per-item `--no-playlist` path that AeroFetch keeps using.
|
||||
- [x] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
|
||||
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
|
||||
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
|
||||
not the directory segments AeroFetch constructs from channel/playlist names.
|
||||
- [ ] **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.
|
||||
|
||||
- [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
|
||||
Downloads / History / Settings. Lists added Sources.
|
||||
- [x] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
|
||||
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
|
||||
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
|
||||
Per-playlist select-all, live counts.
|
||||
- [x] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
|
||||
the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never
|
||||
holds the entire channel at once — the live queue stays small while the Library view is the
|
||||
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
|
||||
records to history exactly as today.
|
||||
- [x] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
|
||||
`store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data
|
||||
so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`).
|
||||
- [x] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
|
||||
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
|
||||
|
||||
## Phase I — Incremental sync & dedup ✅ COMPLETE
|
||||
|
||||
Re-running a channel should grab only what's new — the everyday media-manager loop.
|
||||
|
||||
- [x] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
|
||||
persisted `MediaItem`s and marks only the new ones; existing records (and their
|
||||
`downloaded` state) are preserved. "X new since last sync" badge.
|
||||
- [x] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
|
||||
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
|
||||
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
|
||||
can't re-download.
|
||||
- [ ] **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).
|
||||
|
||||
- [x] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
|
||||
- [x] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
|
||||
(`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new
|
||||
uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15
|
||||
videos, so RSS is for *staying current*, not initial indexing (which stays a full
|
||||
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
|
||||
playlists / initial scans").
|
||||
- [x] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
|
||||
enqueues new items, wired to **Windows Task Scheduler** (reuse the
|
||||
single-instance-lock already added in Phase E so a scheduled run hands off to a running
|
||||
window instead of double-launching). Notify on new downloads via the existing
|
||||
`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.
|
||||
|
||||
- [x] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
|
||||
and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through
|
||||
Phase C custom-command args; a checkbox group in the output settings makes it first-class.
|
||||
|
||||
---
|
||||
|
||||
## 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 F–H are
|
||||
the high-value core; I–K are incremental and each shippable on its own.
|
||||
-440
@@ -1,440 +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.
|
||||
>
|
||||
> **Post-parity work** (YTDLnis-derived editing features + Windows-native/UX polish + reliability)
|
||||
> is tracked at the end of this document under **Post-parity** (Phases L onward).
|
||||
|
||||
---
|
||||
|
||||
## 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 ✅ COMPLETE
|
||||
|
||||
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.
|
||||
- [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 A–C'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).
|
||||
|
||||
---
|
||||
|
||||
# Post-parity — editing, native polish & reliability
|
||||
|
||||
With both parity tracks complete — Seal (Phases A–E above) and the
|
||||
[Pinchflat media-manager roadmap](ROADMAP-PINCHFLAT.md) (Phases F–K ✅) — this final track
|
||||
collects what's left, from two sources:
|
||||
|
||||
1. A **feature study of [YTDLnis](https://github.com/deniscerri/ytdlnis)** (the other major
|
||||
Android yt-dlp frontend), keeping only the features AeroFetch doesn't already have.
|
||||
2. A **Windows-native + daily-use UX gap analysis** of AeroFetch itself (things neither Seal
|
||||
nor Pinchflat cover, but that a desktop app should do).
|
||||
|
||||
Nothing here is built yet — every box is unchecked. Phases continue the existing lettering
|
||||
(A–E Seal, F–K Pinchflat) from **L onward**, and each item carries the yt-dlp flag and the
|
||||
AeroFetch file it touches so it can be driven straight from the existing `buildArgs` /
|
||||
`DownloadOptions` / queue plumbing rather than new infrastructure.
|
||||
|
||||
Sources: [YTDLnis GitHub](https://github.com/deniscerri/ytdlnis) ·
|
||||
[YTDLnis docs](https://ytdlnis.org/) · [YTDLnis changelogs](https://ytdlnis.org/changelogs/) ·
|
||||
[F-Droid listing](https://f-droid.org/packages/com.deniscerri.ytdl/).
|
||||
|
||||
### Already at parity with YTDLnis (do **not** rebuild)
|
||||
|
||||
Format/quality/container picking · SponsorBlock (remove/mark + embed-as-chapters) · embed
|
||||
subtitles/metadata/chapters · cookies + login · custom command templates · backup/restore ·
|
||||
**Observe Sources** (= our Phase J watched sources + RSS) · incognito mode · history with
|
||||
filter + bulk re-download · filename templating · self-managing yt-dlp + ffmpeg binaries.
|
||||
AeroFetch is even with, or ahead of, YTDLnis on all of these.
|
||||
|
||||
## Phase L — Media editing (YTDLnis headline features)
|
||||
|
||||
YTDLnis's signature differentiator. The keyframe plumbing already exists in AeroFetch
|
||||
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
|
||||
`src/main/buildArgs.ts`), so this is cheaper than it looks.
|
||||
|
||||
- [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
|
||||
`parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
|
||||
`--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
|
||||
SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
|
||||
`store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
|
||||
(single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
|
||||
typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
|
||||
only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
|
||||
player, still TODO).*
|
||||
- [x] **Split by chapters into separate files.** → `--split-chapters` (already flagged as a
|
||||
TODO in Phase A above). One toggle on `DownloadOptions` +
|
||||
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
|
||||
keeps the full file and writes per-chapter files via its default `chapter:` template.
|
||||
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
|
||||
still pending.*
|
||||
- [ ] **Metadata editing before download.** Change title / author / artist pre-download
|
||||
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
|
||||
`--replace-in-metadata`; fits the audio path. Good for building a clean music library
|
||||
where the source title is messy. *Held: the exact set-a-literal recipe is fragile
|
||||
(`--parse-metadata FROM:TO` splits on a colon that titles often contain;
|
||||
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
|
||||
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
|
||||
flag.*
|
||||
|
||||
## Phase M — Queue & daily-use UX ✅ COMPLETE
|
||||
|
||||
Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in.
|
||||
Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
|
||||
|
||||
- [x] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
|
||||
(already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the
|
||||
`taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so
|
||||
the close/error handlers stay silent (no error event/log/notification); new
|
||||
`download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) +
|
||||
a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting
|
||||
progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress
|
||||
row in `QueueItem.tsx`; paused items survive "Clear finished". typecheck + test green;
|
||||
live resume-continues-the-.part still wants a smoke test.*
|
||||
- [x] **Reorder / prioritize the queue.** *Done as a **"Download next"** action rather than
|
||||
drag: since the queue renders newest-first while `pump()` promotes oldest-first, a
|
||||
reposition primitive is clearer than drag. `prioritize(id)` (`store/downloads.ts`) moves a
|
||||
queued item to the front of the promotion order; an up-arrow button on queued rows
|
||||
(`QueueItem.tsx`).*
|
||||
- [x] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
|
||||
view summing bytes/speed across active items. Feeds straight into Phase O's taskbar
|
||||
progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in
|
||||
`DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested
|
||||
(`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach —
|
||||
we don't track per-item sizes — so "time left" is the longest single ETA.*
|
||||
- [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card
|
||||
(`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight,
|
||||
accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url`
|
||||
Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by
|
||||
the existing `looksLikeUrl`. A dropped link fills the URL field.*
|
||||
- [x] **Retry all failed.** One action that re-enqueues every failed item. *Done:
|
||||
`retryAll()` in `store/downloads.ts` re-queues all `error` items + a "Retry all failed (N)"
|
||||
button in `DownloadsView.tsx`. (Re-queues live queue items rather than reading
|
||||
`errorlog.json` — same effect for the on-screen failures.)*
|
||||
- [x] **Duplicate detection.** Warn ("you already have this") before enqueuing a URL already in
|
||||
the active queue. *Done: pure `sameVideo()` (`store/queueStats.ts`, YouTube-id aware +
|
||||
normalised-URL fallback) drives a "Already in your queue — Download anyway?" banner in
|
||||
`DownloadBar.tsx`; unit-tested. Checks the live queue (which still holds recently-completed
|
||||
items); cross-checking `history.json` for older downloads is a possible extension.*
|
||||
- [x] **Per-download scheduling + "save for later."** *Done: a `saved` status + optional
|
||||
`scheduledFor` (`store/downloads.ts`); `pump()` ignores `saved` (the time gate), and a 15s
|
||||
ticker promotes due scheduled items to `queued`. UI: a **Schedule** panel with a native
|
||||
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
|
||||
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
|
||||
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
|
||||
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
|
||||
AeroFetch is running** — noted in the UI hint and the code.*
|
||||
|
||||
## Phase N — Power-user surface ✅ COMPLETE
|
||||
|
||||
YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates,
|
||||
per-item `options`) but not the surfaces. typecheck + test + `npm run build` clean.
|
||||
|
||||
- [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with
|
||||
raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming
|
||||
stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel`
|
||||
IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter)
|
||||
and a Terminal sidebar tab.*
|
||||
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
|
||||
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
|
||||
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
|
||||
default). Per-item exact-format selection (needs probing each entry) left as a further
|
||||
extension.*
|
||||
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
|
||||
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
|
||||
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
|
||||
- [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex);
|
||||
`selectExtraArgs` auto-applies a matching template ahead of the global default
|
||||
(`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern
|
||||
field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.*
|
||||
- [x] **Command palette + keyboard shortcuts.** *Done: a portal-free `CommandPalette.tsx`
|
||||
(filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs,
|
||||
new download (focuses the `aerofetch-url` input), toggle theme.*
|
||||
|
||||
## Phase O — Windows-native integration & discoverability ✅ COMPLETE
|
||||
|
||||
All in the main process. typecheck + test + `npm run build` clean.
|
||||
|
||||
- [x] **Taskbar progress bar.** *Done: renderer pushes the `summarizeQueue` aggregate over a new
|
||||
`taskbar:progress` channel (App-level store subscription, no re-render);
|
||||
`mainWindow.setProgressBar` with `mode: 'normal' | 'error'` (red when any failed) and `-1`
|
||||
to clear when idle.*
|
||||
- [x] **System tray + minimize-to-tray.** *Done: `src/main/tray.ts` builds a `Tray` (icon resolved
|
||||
via `getAppIconPath()`; `build/icon.ico` added to electron-builder `extraResources` for
|
||||
prod) with Show / Quit. A new `Settings.minimizeToTray` (Settings → Appearance) makes the
|
||||
window's `close` hide to tray instead of quitting; `before-quit`/tray-Quit set an
|
||||
`isQuitting` flag so real exits still work.*
|
||||
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
|
||||
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
|
||||
assets this repo doesn't have tooling to generate here.*
|
||||
- [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
|
||||
*Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the
|
||||
taskbar progress bar's `error` mode already signals failures.*
|
||||
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
|
||||
live by matching each card's text (DOM `display` toggle keyed off the root's children — no
|
||||
per-card refactor), with a "no settings match" hint.*
|
||||
|
||||
## Phase P — Reliability, longevity & trust ✅ (code shipped; cert + manual passes still required)
|
||||
|
||||
The least glamorous, highest-leverage track. The buildable code is done (typecheck + test +
|
||||
build clean); the parts that inherently need a human or a paid cert are delivered as artifacts.
|
||||
|
||||
- [x] **PO Token / YouTube reliability plumbing.** *Done: `Settings.youtubePlayerClient` +
|
||||
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
|
||||
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
|
||||
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
|
||||
**Deferred: automatic WebView token minting** (the YTDLnis headline) — the hard part;
|
||||
this is the argv plumbing it would feed. Cookies remain the easier bot-check fix.
|
||||
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
|
||||
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
|
||||
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
|
||||
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
|
||||
steps + expected results. **Still needs a human to run it.**
|
||||
- [x] **Code signing.** *Build is signing-ready: electron-builder picks the cert up from
|
||||
`CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change (unset = unsigned, as today). Documented
|
||||
in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV, local + CI, HSM/EV hook, signature
|
||||
verification). **Actual signing needs a purchased certificate.***
|
||||
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
|
||||
picker that doesn't change anything would be misleading; doing it properly means wiring an
|
||||
i18n library + extracting every string, with translations arriving incrementally. Tracked,
|
||||
not started.
|
||||
|
||||
---
|
||||
|
||||
## Post-parity — what we reuse vs. build
|
||||
|
||||
**Reuse unchanged:** `buildArgs`/`buildCommand` flag emission, `DownloadOptions` +
|
||||
`DownloadOptionsForm`, the renderer queue scheduler + concurrency cap + per-item `options`,
|
||||
the persisted cookies `BrowserWindow`, the probe's `chapters[]`/format data, `errorlog.ts`,
|
||||
`CommandTemplate`s, `extractIncomingUrl()`, and the self-managing binary updater.
|
||||
|
||||
**Build new:** the trim/split UI + `--download-sections`/`--split-chapters` wiring (L), the
|
||||
pause/reorder/schedule queue states (M), the terminal view + per-item playlist editor (N), the
|
||||
main-process taskbar/tray/jumplist integration (O), and the PO-token provider (P).
|
||||
|
||||
## Post-parity — suggested order
|
||||
|
||||
**L is the high-value headline** — trim + split-chapters share one UI surface and the keyframe
|
||||
plumbing already exists, so they land cheaply and give AeroFetch YTDLnis's marquee capability.
|
||||
Then **P's PO-token + verify-wiring** as a reliability block (keeps YouTube working and de-risks
|
||||
shipped features). **M** and **O** are the daily-use polish and pair well (aggregate progress →
|
||||
taskbar bar). **N** is the power-user surface, each item independently shippable. Each phase is
|
||||
self-contained and rebuild-gated (`npm run typecheck` + `npm run test` + `npm run build`) the
|
||||
same way Phases A–K were.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 361 KiB |
+23
-9
@@ -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 |
@@ -1,77 +0,0 @@
|
||||
# AeroFetch — Windows code signing
|
||||
|
||||
Unsigned builds run, but Windows SmartScreen shows a blue **"Windows protected your PC"**
|
||||
prompt the first time each user runs the installer (they must click *More info → Run anyway*).
|
||||
This is the single biggest first-run friction when sharing AeroFetch. Code signing removes it
|
||||
(EV certificates remove it immediately; OV certificates build reputation over time/installs).
|
||||
|
||||
**This step needs a certificate that can't live in the repo**, so it isn't wired on by default.
|
||||
The build is otherwise ready for it — electron-builder picks the cert up from environment
|
||||
variables with no `electron-builder.yml` changes required.
|
||||
|
||||
## What you need
|
||||
|
||||
- An **Authenticode code-signing certificate** for Windows:
|
||||
- **OV (Organization Validation)** — cheaper; SmartScreen reputation accrues as more users
|
||||
install, so early downloads still see the prompt.
|
||||
- **EV (Extended Validation)** — pricier, usually on a hardware token / cloud HSM; clears
|
||||
SmartScreen immediately. Token-based EV can't be fed as a plain `.pfx` (see "HSM/EV" below).
|
||||
- The cert as a password-protected `.pfx`/`.p12` (for OV), or an HSM/cloud-signing setup (EV).
|
||||
|
||||
## Signing an OV `.pfx` locally
|
||||
|
||||
electron-builder reads these env vars automatically:
|
||||
|
||||
```bash
|
||||
# PowerShell
|
||||
$env:CSC_LINK = "C:\path\to\codesign.pfx" # or a base64 string of the .pfx
|
||||
$env:CSC_KEY_PASSWORD = "<pfx password>"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash / CI
|
||||
export CSC_LINK="$(base64 -w0 codesign.pfx)" # base64 is convenient for CI secrets
|
||||
export CSC_KEY_PASSWORD="$PFX_PASSWORD"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
electron-builder then signs `AeroFetch Setup <ver>.exe`, the portable `.exe`, and the app
|
||||
binary. No changes to `electron-builder.yml` are needed; if `CSC_LINK` is unset, the build
|
||||
proceeds **unsigned** (current behavior).
|
||||
|
||||
## CI (GitHub Actions sketch)
|
||||
|
||||
Store the cert + password as encrypted secrets and export them before the build:
|
||||
|
||||
```yaml
|
||||
- name: Build (signed)
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} # base64 of the .pfx
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||
run: npm run build:win
|
||||
```
|
||||
|
||||
## HSM / EV certificates
|
||||
|
||||
EV certs (and many newer OV certs) ship on a hardware token or cloud HSM and can't be exported
|
||||
to a `.pfx`. Sign via a custom signing hook instead — set `win.sign` in `electron-builder.yml`
|
||||
to a script that invokes your provider's tool (Azure Trusted Signing, DigiCert KeyLocker,
|
||||
SSL.com eSigner, or `signtool` with the token). Each provider documents the exact `signtool`
|
||||
invocation; electron-builder calls your hook once per artifact with the file path.
|
||||
|
||||
## Verifying a signature
|
||||
|
||||
```powershell
|
||||
Get-AuthenticodeSignature ".\dist\AeroFetch Setup <ver>.exe" | Format-List
|
||||
# Status should be 'Valid'; SignerCertificate should be your cert.
|
||||
signtool verify /pa /v ".\dist\AeroFetch Setup <ver>.exe"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The LGPL ffmpeg + yt-dlp binaries shipped in `resources/bin` are third-party; signing the
|
||||
AeroFetch artifacts doesn't (and needn't) re-sign them.
|
||||
- Timestamp the signature (electron-builder does by default) so it stays valid after the cert
|
||||
expires.
|
||||
- Signing does **not** replace the smoke test — see [SMOKE-TEST.md](SMOKE-TEST.md).
|
||||
@@ -1,61 +0,0 @@
|
||||
# AeroFetch — manual smoke-test checklist
|
||||
|
||||
Some features can only be exercised against a **real install** (or a real yt-dlp run) —
|
||||
they're main-process / Electron / OS-integration code that the Vite browser preview and the
|
||||
unit suite can't reach. They're implemented and typecheck/test/build clean, but the roadmaps
|
||||
flag them as needing a hands-on pass. Run this checklist after building
|
||||
(`npm run build:win`) and installing the NSIS artifact (or launching the portable `.exe`).
|
||||
|
||||
Mark each ✅/❌ and note anything surprising.
|
||||
|
||||
## Downloads & post-processing (Phases A, L, M)
|
||||
|
||||
- [ ] **Basic download** — paste a URL, Fetch, pick a format, Download → file lands in
|
||||
Documents\Video (or your folder), progress + speed update, "Completed".
|
||||
- [ ] **Trim / cut** (Phase L) — set a section like `0:10-0:20`, download → output is ~10s and
|
||||
starts/ends near the cut points (`--download-sections` + `--force-keyframes-at-cuts`).
|
||||
- [ ] **Split by chapters** (Phase L) — on a video with chapters, enable it → per-chapter files
|
||||
appear alongside the full file.
|
||||
- [ ] **Pause / resume** (Phase M) — start a large download, Pause (process stops, `.part`
|
||||
stays), Resume → it **continues** from where it stopped, not from 0% (yt-dlp `--continue`).
|
||||
- [ ] **Scheduling** (Phase M) — schedule a download a couple of minutes out → it sits "Saved /
|
||||
Scheduled", then auto-starts at the time (only while the app is running).
|
||||
|
||||
## Access & networking (Phase B, P)
|
||||
|
||||
- [ ] **Cookies — sign-in window** — Settings → Cookies → "Sign-in window", log into a site,
|
||||
close it → "cookies saved" with a timestamp; a members-only/age-gated video then downloads.
|
||||
- [ ] **Cookies — from browser** — pick an installed browser → a gated video downloads.
|
||||
- [ ] **Proxy** — set a working proxy → downloads route through it (verify via the proxy logs).
|
||||
- [ ] **aria2c** — drop `aria2c.exe` into `resources/bin`, enable it → multi-connection download
|
||||
works; remove it → silently falls back to yt-dlp's downloader.
|
||||
- [ ] **YouTube client / PO token** (Phase P) — set `youtubePlayerClient` (e.g. `web_safari`) →
|
||||
the run includes `--extractor-args youtube:player_client=web_safari` (check Terminal/Preview).
|
||||
|
||||
## OS integration (Phase E, J, O)
|
||||
|
||||
- [ ] **`aerofetch://` protocol** — from a browser, open `aerofetch://download?url=<encoded>` →
|
||||
AeroFetch focuses and the URL appears in the "Link received" banner.
|
||||
- [ ] **Explorer "Send to AeroFetch"** — drag a tab to the desktop to make a `.url`, right-click →
|
||||
Send to → AeroFetch → the link is received.
|
||||
- [ ] **Scheduled sync** (Phase J) — Settings → enable the daily sync → a Windows Task Scheduler
|
||||
task exists; run it (or `AeroFetch.exe --sync`) → watched sources index and new items enqueue.
|
||||
- [ ] **RSS fast-check** (Phase J) — "Check for new" on a watched channel → newly-posted videos
|
||||
show as new without a full re-index.
|
||||
- [ ] **System tray** (Phase O) — enable "Keep running in the tray", close the window → it hides
|
||||
to the tray; tray click reopens; tray → Quit actually exits.
|
||||
- [ ] **Taskbar progress** (Phase O) — during downloads the taskbar icon shows a progress bar;
|
||||
it turns red if one fails and clears when the queue goes idle.
|
||||
- [ ] **Jump list** (Phase O) — right-click the taskbar icon → "Open AeroFetch" focuses the app.
|
||||
|
||||
## Terminal (Phase N)
|
||||
|
||||
- [ ] Enable "Run custom commands", open Terminal, run `--version` → the version prints; run
|
||||
`-F <url>` → the format table streams in; Stop cancels a long run.
|
||||
|
||||
---
|
||||
|
||||
If anything here fails, capture the Settings → Diagnostics "Copy full report" output and the
|
||||
exact steps. These items have no automated coverage, so this checklist is the release gate for
|
||||
them. See also [SIGNING.md](SIGNING.md) for the code-signing step that removes the SmartScreen
|
||||
prompt for end users.
|
||||
+22
-8
@@ -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,32 @@ 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
|
||||
@@ -46,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
|
||||
|
||||
@@ -63,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).
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Generated
+1231
-3
File diff suppressed because it is too large
Load Diff
+22
-4
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
"homepage": "https://github.com/yt-dlp/yt-dlp",
|
||||
"license": "UNLICENSED",
|
||||
"homepage": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,12 +19,18 @@
|
||||
"ui:build": "vite build",
|
||||
"ui:preview": "vite preview",
|
||||
"build": "electron-vite build",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"clean:dist": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"build:win": "npm run clean:dist && electron-vite build && electron-builder --win",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"test": "vitest run"
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:integration": "set AEROFETCH_REAL_DOWNLOAD=1&& vitest run test/real-download.integration.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
@@ -29,10 +40,12 @@
|
||||
"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",
|
||||
@@ -40,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
@@ -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.
@@ -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
|
||||
+39
-24
@@ -1,14 +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 { isTemplateLike } from './validation'
|
||||
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
|
||||
@@ -17,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 }
|
||||
@@ -32,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
|
||||
@@ -52,9 +55,13 @@ 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
|
||||
@@ -65,7 +72,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
: []
|
||||
|
||||
// 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.
|
||||
@@ -73,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) {
|
||||
@@ -85,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,
|
||||
@@ -100,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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+65
-16
@@ -1,4 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { app, nativeImage, type NativeImage } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
|
||||
@@ -24,18 +24,40 @@ export function getAppIconPath(): string {
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
}
|
||||
|
||||
let appIconImage: NativeImage | null = null
|
||||
/**
|
||||
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
|
||||
* from the read-only bundled seed in resources/bin. The managed copy is what
|
||||
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
|
||||
* portable re-extraction — which only ever replace the bundled seed — can never
|
||||
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
|
||||
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
|
||||
*
|
||||
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
|
||||
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
|
||||
* 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 getManagedBinDir(): string {
|
||||
export function getAppIconImage(): NativeImage {
|
||||
// Cache only a valid image so a transient read miss isn't latched forever; a
|
||||
// genuinely-missing icon just re-reads (cheap — notifications are infrequent).
|
||||
if (!appIconImage || appIconImage.isEmpty()) {
|
||||
appIconImage = nativeImage.createFromPath(getAppIconPath())
|
||||
}
|
||||
return appIconImage
|
||||
}
|
||||
|
||||
/**
|
||||
* AeroFetch keeps its OWN writable 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')
|
||||
}
|
||||
|
||||
@@ -49,20 +71,47 @@ 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(getBinDir(), 'ffmpeg.exe')
|
||||
return join(getManagedBinDir(), 'ffmpeg.exe')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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')
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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 a PUBLIC, releases-only repo — it holds no source, just
|
||||
* the published installers — so anonymous reads work and the updater sends no
|
||||
* auth at all. Keeping releases separate from the (private) source repo is what
|
||||
* lets the client stay tokenless: there is no secret in the bundle to leak. 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-releases'
|
||||
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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
+133
-18
@@ -1,15 +1,17 @@
|
||||
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
||||
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
||||
import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { logger } from './logger'
|
||||
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
||||
|
||||
/**
|
||||
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
|
||||
* from the main window's (default) session so a logged-in site can't see or
|
||||
* touch anything the app itself loads.
|
||||
* touch anything the app itself loads. Exported so the PO-token window can
|
||||
* share the same session (and thus the user's YouTube sign-in state).
|
||||
*/
|
||||
const PARTITION = 'persist:aerofetch-login'
|
||||
export const LOGIN_PARTITION = 'persist:aerofetch-login'
|
||||
|
||||
/**
|
||||
* The sign-in window renders untrusted remote content, so every navigation and
|
||||
@@ -43,7 +45,7 @@ function hardenLoginWebContents(wc: WebContents): void {
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
partition: PARTITION,
|
||||
partition: LOGIN_PARTITION,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
@@ -60,20 +62,99 @@ function hardenLoginWebContents(wc: WebContents): void {
|
||||
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
|
||||
}
|
||||
|
||||
export function getCookiesFilePath(): string {
|
||||
// The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
|
||||
// a plaintext file, so the stored form is encrypted via Electron safeStorage
|
||||
// (DPAPI on Windows — the same protection settings secrets get) and only ever
|
||||
// decrypted to a short-lived temp file for the duration of a download. This
|
||||
// matters for the portable build, where userData sits next to the exe (USB /
|
||||
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
|
||||
function cookieStorePath(): string {
|
||||
return join(app.getPath('userData'), 'cookies.dat')
|
||||
}
|
||||
|
||||
function legacyCookiePath(): string {
|
||||
return join(app.getPath('userData'), 'cookies.txt')
|
||||
}
|
||||
|
||||
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
|
||||
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
|
||||
const ENC_PREFIX = 'enc:v1:'
|
||||
|
||||
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
|
||||
function storeCookies(netscape: string): void {
|
||||
let out = netscape
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
|
||||
}
|
||||
} catch {
|
||||
/* fall through — store plaintext, as it was before encryption existed */
|
||||
}
|
||||
writeFileSync(cookieStorePath(), out)
|
||||
}
|
||||
|
||||
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
|
||||
function loadCookies(): string | null {
|
||||
const p = cookieStorePath()
|
||||
if (!existsSync(p)) return null
|
||||
const stored = readFileSync(p, 'utf8')
|
||||
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
|
||||
} catch {
|
||||
// A different Windows user/machine (portable jar copied elsewhere) or a
|
||||
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
|
||||
* encrypted store, deleting the plaintext so a logged-in session no longer sits
|
||||
* in the open after an update (H7). Best-effort; safe to call on every launch.
|
||||
*/
|
||||
export function migrateLegacyCookies(): void {
|
||||
const legacy = legacyCookiePath()
|
||||
try {
|
||||
if (!existsSync(legacy)) return
|
||||
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
|
||||
unlinkSync(legacy)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a stored cookie jar exists (regardless of decryptability here). */
|
||||
export function hasStoredCookies(): boolean {
|
||||
return existsSync(cookieStorePath())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
|
||||
* false when there's nothing usable to write. The caller deletes `dest` once the
|
||||
* download settles, keeping the plaintext window as short as possible.
|
||||
*/
|
||||
export function materializeCookies(dest: string): boolean {
|
||||
const text = loadCookies()
|
||||
if (text == null) return false
|
||||
try {
|
||||
writeFileSync(dest, text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getCookiesStatus(): CookiesStatus {
|
||||
const p = getCookiesFilePath()
|
||||
const p = cookieStorePath()
|
||||
if (!existsSync(p)) return { exists: false }
|
||||
return { exists: true, savedAt: statSync(p).mtimeMs }
|
||||
}
|
||||
|
||||
export async function clearCookies(): Promise<void> {
|
||||
const p = getCookiesFilePath()
|
||||
const p = cookieStorePath()
|
||||
if (existsSync(p)) unlinkSync(p)
|
||||
await session.fromPartition(PARTITION).clearStorageData()
|
||||
await session.fromPartition(LOGIN_PARTITION).clearStorageData()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +170,15 @@ function toNetscapeCookieFile(cookies: Cookie[]): string {
|
||||
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
|
||||
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
|
||||
lines.push(
|
||||
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
|
||||
'\t'
|
||||
)
|
||||
[
|
||||
domain,
|
||||
includeSubdomains,
|
||||
c.path || '/',
|
||||
c.secure ? 'TRUE' : 'FALSE',
|
||||
expiry,
|
||||
c.name,
|
||||
c.value
|
||||
].join('\t')
|
||||
)
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
@@ -102,10 +189,13 @@ let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
|
||||
|
||||
function exportAndResolve(): void {
|
||||
session
|
||||
.fromPartition(PARTITION)
|
||||
.fromPartition(LOGIN_PARTITION)
|
||||
.cookies.get({})
|
||||
.then((cookies) => {
|
||||
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
|
||||
// Don't persist an empty jar: closing the window without signing in would
|
||||
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
|
||||
// turns cookieCount === 0 into a "did you sign in?" hint.
|
||||
if (cookies.length > 0) storeCookies(toNetscapeCookieFile(cookies))
|
||||
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
|
||||
pendingResolvers.forEach((resolve) => resolve(result))
|
||||
})
|
||||
@@ -124,7 +214,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 {
|
||||
@@ -136,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
|
||||
}
|
||||
@@ -148,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
|
||||
@@ -170,13 +272,26 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
||||
hardenLoginWebContents(win.webContents)
|
||||
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
|
||||
|
||||
// 'close' fires on a normal user close; a programmatic destroy() fires only
|
||||
// 'closed'. Latch whichever runs first so the partition is exported exactly
|
||||
// once and the promise can never hang (B7) — without this, a destroy()
|
||||
// without a preceding 'close' would leave pendingResolvers pending forever.
|
||||
let exportStarted = false
|
||||
const exportOnce = (): void => {
|
||||
if (exportStarted) return
|
||||
exportStarted = true
|
||||
exportAndResolve()
|
||||
}
|
||||
win.on('closed', () => {
|
||||
loginWindow = null
|
||||
// Fallback for destroy()/closed-without-close: the partition outlives the
|
||||
// window, so a late export still captures whatever cookies were collected.
|
||||
exportOnce()
|
||||
})
|
||||
// Closing the window IS "I'm done" — export whatever the partition
|
||||
// collected. The partition itself outlives the window, so this is safe
|
||||
// even though cookies.get() resolves after 'close' has already fired.
|
||||
win.on('close', exportAndResolve)
|
||||
win.on('close', exportOnce)
|
||||
|
||||
win.loadURL(validUrl).catch(() => {
|
||||
/* navigation errors surface as Chromium's own error page */
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* 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()`.
|
||||
* `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'
|
||||
@@ -42,9 +43,7 @@ function videoFormat(quality: string): string {
|
||||
*/
|
||||
function videoSelector(opts: StartDownloadOptions): string {
|
||||
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
||||
return opts.formatHasAudio
|
||||
? opts.formatId
|
||||
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||
return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||
}
|
||||
return videoFormat(opts.quality)
|
||||
}
|
||||
@@ -57,18 +56,33 @@ function audioQuality(quality: string): string {
|
||||
return '192K'
|
||||
case '128 kbps':
|
||||
return '128K'
|
||||
case 'Best':
|
||||
return '0'
|
||||
default:
|
||||
return '0' // Best
|
||||
// Unrecognised label — fall back to best (0) so the download still works.
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
|
||||
// Stdout line markers, shared by the emit side (the --print templates here) and
|
||||
// the parse side (download.ts). Defined once so changing a marker can't silently
|
||||
// break the parser that splits on it (CL1).
|
||||
export const PROGRESS_MARKER = 'prog|'
|
||||
export const FILEPATH_MARKER = 'path|'
|
||||
// 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 literal `prog|` that follows is our
|
||||
// (it does NOT appear in the output). The PROGRESS_MARKER that follows is our
|
||||
// own marker, so we can tell progress lines apart from the after-move filepath
|
||||
// print on the same stdout stream.
|
||||
export const PROGRESS_TEMPLATE =
|
||||
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
||||
const PROGRESS_TEMPLATE =
|
||||
`download:${PROGRESS_MARKER}%(progress.status)s|%(progress.downloaded_bytes)s|` +
|
||||
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||
'%(progress.speed)s|%(progress.eta)s'
|
||||
|
||||
@@ -77,8 +91,7 @@ export const PROGRESS_TEMPLATE =
|
||||
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||
export const CROP_SQUARE_PPA =
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + "crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
|
||||
|
||||
/**
|
||||
* Phase B "Access & networking" settings — not per-download options, always
|
||||
@@ -90,8 +103,16 @@ export interface AccessOptions {
|
||||
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 1–16; 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) */
|
||||
@@ -106,9 +127,23 @@ export interface AccessOptions {
|
||||
youtubePoToken?: 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'
|
||||
/**
|
||||
* 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
|
||||
@@ -127,7 +162,9 @@ export function parseExtraArgs(raw: string): string[] {
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
args.push(m[1] ?? m[2] ?? m[3])
|
||||
// Exactly one of the three alternation groups matches; the '' fallback only
|
||||
// satisfies the type checker (noUncheckedIndexedAccess) and never fires.
|
||||
args.push(m[1] ?? m[2] ?? m[3] ?? '')
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -142,24 +179,24 @@ export function parseExtraArgs(raw: string): string[] {
|
||||
* per-download override wins over the persisted default template, but NEITHER is
|
||||
* applied while the gate is off. This keeps a compromised renderer from smuggling
|
||||
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
|
||||
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
|
||||
* first have to flip the visible `enableCustomCommands` setting, leaving a trace
|
||||
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
|
||||
*
|
||||
* - customCommandEnabled off → [] (always)
|
||||
* - 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: {
|
||||
customCommandEnabled: boolean
|
||||
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.customCommandEnabled) return []
|
||||
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.
|
||||
@@ -197,7 +234,10 @@ export function matchesUrl(pattern: string, url: string): boolean {
|
||||
*/
|
||||
export function parseTrimSections(raw: string | undefined): string[] {
|
||||
if (!raw) return []
|
||||
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
|
||||
// A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old
|
||||
// `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside
|
||||
// yt-dlp's --download-sections rather than being rejected up front (L146).
|
||||
const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?`
|
||||
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
||||
const out: string[] = []
|
||||
for (const piece of raw.split(/[,\n]/)) {
|
||||
@@ -244,7 +284,11 @@ export function sanitizeDirSegment(name: string): string {
|
||||
// byte ever appears in this source file.
|
||||
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
|
||||
s = s.replace(/[<>:"/\\|?*]/g, ' ')
|
||||
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
|
||||
s = s
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[. ]+$/, '')
|
||||
.replace(/^[. ]+/, '')
|
||||
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
|
||||
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
|
||||
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
|
||||
@@ -253,17 +297,29 @@ export function sanitizeDirSegment(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `-o` output template for a collection download:
|
||||
* 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
|
||||
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)]
|
||||
@@ -274,7 +330,22 @@ 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)
|
||||
// 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.
|
||||
@@ -328,7 +399,23 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||
if (o.splitChapters) args.push('--split-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
// Metadata embedding + optional per-field overrides.
|
||||
// --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata
|
||||
// FROM:TO because the replacement arg is a separate element (no colon-splitting
|
||||
// on values like "Foo: Bar"). ^.*$ matches any string including empty. The only
|
||||
// escaping Python's re.sub needs in the replacement string is backslash.
|
||||
const metaOverrides: [string, string | undefined][] = [
|
||||
['title', o.metadataTitle],
|
||||
['artist', o.metadataArtist],
|
||||
['album', o.metadataAlbum]
|
||||
]
|
||||
const hasOverrides = metaOverrides.some(([, v]) => v?.trim())
|
||||
if (o.embedMetadata || hasOverrides) args.push('--embed-metadata')
|
||||
const escRepl = (s: string): string => s.replace(/\\/g, '\\\\')
|
||||
for (const [field, val] of metaOverrides) {
|
||||
if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim()))
|
||||
}
|
||||
|
||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
|
||||
@@ -339,18 +426,39 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
return args
|
||||
}
|
||||
|
||||
export function buildArgs(
|
||||
opts: StartDownloadOptions,
|
||||
outputTemplate: string,
|
||||
o: DownloadOptions,
|
||||
binDir: string,
|
||||
access: AccessOptions,
|
||||
extraArgs: string[] = []
|
||||
): string[] {
|
||||
/**
|
||||
* Everything buildArgs needs to construct a yt-dlp argv. A single options object
|
||||
* (rather than six positional params) so callers can't transpose `opts`/`options`
|
||||
* or the two path-like strings, and new inputs can be added without churning every
|
||||
* call site (CL2).
|
||||
*/
|
||||
export interface BuildArgsInput {
|
||||
/** The per-download request (url, kind, quality, chosen format, trim, …). */
|
||||
opts: StartDownloadOptions
|
||||
/** The resolved `-o` output template (flat filename or collection folder tree). */
|
||||
outputTemplate: string
|
||||
/** The post-processing options group (per-download override or the persisted default). */
|
||||
options: DownloadOptions
|
||||
/** ffmpeg/yt-dlp bin dir, passed in so this module stays free of path resolution. */
|
||||
binDir: string
|
||||
/** Global access/networking settings (proxy, cookies, rate limit, …). */
|
||||
access: AccessOptions
|
||||
/** Custom-command extra args, already consent-gated by the caller. */
|
||||
extraArgs?: string[]
|
||||
}
|
||||
|
||||
export function buildArgs(input: BuildArgsInput): string[] {
|
||||
const { opts, outputTemplate, options: o, binDir, access, extraArgs = [] } = input
|
||||
const args = [
|
||||
'--newline',
|
||||
'--no-color',
|
||||
'--no-playlist',
|
||||
// Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a
|
||||
// stalled socket instead of hanging forever and pinning a concurrency slot
|
||||
// with no recovery. The app-side idle watchdog in download.ts is the backstop
|
||||
// for a fully-wedged process. (B1)
|
||||
'--socket-timeout',
|
||||
'30',
|
||||
// --print (below) implies --quiet, which would suppress progress; --progress
|
||||
// forces the progress template to emit anyway.
|
||||
'--progress',
|
||||
@@ -362,7 +470,11 @@ export function buildArgs(
|
||||
PROGRESS_TEMPLATE,
|
||||
// Print the final path after post-processing/move so we can open it later.
|
||||
'--print',
|
||||
'after_move:path|%(filepath)s',
|
||||
`after_move:${FILEPATH_MARKER}%(filepath)s`,
|
||||
// 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'
|
||||
]
|
||||
|
||||
@@ -370,7 +482,11 @@ export function buildArgs(
|
||||
args.push(...postProcessArgs(opts, o))
|
||||
|
||||
if (opts.kind === 'audio') {
|
||||
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
||||
// --audio-quality is a bitrate selector meaningful only for lossy re-encodes.
|
||||
// For lossless formats (flac/wav) it is silently ignored by yt-dlp (M21).
|
||||
const lossless = o.audioFormat === 'flac' || o.audioFormat === 'wav'
|
||||
args.push('-x', '--audio-format', o.audioFormat)
|
||||
if (!lossless) args.push('--audio-quality', audioQuality(opts.quality))
|
||||
if (o.embedThumbnail) {
|
||||
args.push('--embed-thumbnail')
|
||||
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||
@@ -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.
|
||||
*
|
||||
@@ -88,7 +88,10 @@ export function classifySource(raw: string): SourceClass | null {
|
||||
// A playlist is identified purely by its list= param (works on any youtube host).
|
||||
const list = u.searchParams.get('list')
|
||||
if (isYouTube && list) {
|
||||
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
|
||||
return {
|
||||
kind: 'playlist',
|
||||
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!isYouTube) return null
|
||||
@@ -163,7 +166,7 @@ export function parseRssVideoIds(xml: string): string[] {
|
||||
const ids: string[] = []
|
||||
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(xml)) !== null) ids.push(m[1])
|
||||
while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -219,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,7 +7,14 @@
|
||||
*/
|
||||
|
||||
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) ---------------------------------
|
||||
|
||||
@@ -67,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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,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
|
||||
@@ -21,5 +21,10 @@ export function shouldAutoCheckYtdlp(
|
||||
): boolean {
|
||||
if (!enabled) return false
|
||||
if (!lastCheck) return true
|
||||
// A backward system-clock correction can leave `lastCheck` in the "future"
|
||||
// relative to `now`, making `now - lastCheck` negative so the daily check would
|
||||
// never come due again. Treat a future timestamp as skewed/bogus and run the
|
||||
// check rather than waiting out a negative interval (R9).
|
||||
if (lastCheck > now) return true
|
||||
return now - lastCheck >= intervalMs
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { app, shell, type BrowserWindow } from 'electron'
|
||||
import { existsSync, openSync, readSync, closeSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { parseUrlShortcutContent } from '@shared/ipc'
|
||||
|
||||
/** Only ever forward http(s) targets into the app — same restriction the
|
||||
* external-link window-open handler in index.ts applies to in-page links.
|
||||
@@ -29,8 +30,8 @@ function readUrlShortcut(path: string): string | null {
|
||||
fd = openSync(path, 'r')
|
||||
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
|
||||
const bytes = readSync(fd, buf, 0, buf.length, 0)
|
||||
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
|
||||
return match ? asHttpUrl(match[1].trim()) : null
|
||||
const url = parseUrlShortcutContent(buf.toString('utf8', 0, bytes))
|
||||
return url ? asHttpUrl(url) : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
|
||||
+359
-146
@@ -1,29 +1,46 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { existsSync, unlinkSync, readdirSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, parse } from 'path'
|
||||
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||
import {
|
||||
getYtdlpPath,
|
||||
getBinDir,
|
||||
getFfmpegDir,
|
||||
getAria2cPath,
|
||||
getFfmpegPath,
|
||||
getFfprobePath,
|
||||
getSystem32Path
|
||||
getSystem32Path,
|
||||
getAppIconImage,
|
||||
YTDLP_MISSING_MSG
|
||||
} from './binaries'
|
||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||
import { getSettings } from './settings'
|
||||
import { execFileAsync } from './lib/exec'
|
||||
import { createLineBuffer } from './lib/lineBuffer'
|
||||
import { orphanPartials } from './lib/partials'
|
||||
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
|
||||
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
|
||||
import { ensureManagedYtdlp } from './ytdlp'
|
||||
import { getCookiesFilePath } from './cookies'
|
||||
import { materializeCookies, hasStoredCookies } from './cookies'
|
||||
import { listTemplates } from './templates'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { isSafeOutputDir } from './validation'
|
||||
import { isSafeOutputDir } from './core/validation'
|
||||
import {
|
||||
buildArgs,
|
||||
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,
|
||||
@@ -31,7 +48,6 @@ import {
|
||||
type CommandPreviewResult,
|
||||
type DownloadEvent,
|
||||
type DownloadMeta,
|
||||
type DownloadProgress,
|
||||
type Settings
|
||||
} from '@shared/ipc'
|
||||
|
||||
@@ -43,64 +59,47 @@ interface ActiveDownload {
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
|
||||
// Per-spawn sequence, so a retry/resume that reuses the item id still gets a
|
||||
// unique transient cookie file (below) — otherwise a superseded download's
|
||||
// teardown could unlink the new spawn's jar mid-read.
|
||||
let spawnSeq = 0
|
||||
|
||||
// Remove an item from the active map, but only if THIS rec still owns the slot.
|
||||
// cancel/pause release the slot synchronously (so the renderer's just-promoted
|
||||
// next item isn't rejected by the maxConcurrent guard while the killed tree is
|
||||
// still tearing down); the doomed child's later 'close'/'error' then runs this as
|
||||
// a no-op. The identity check matters because a retry/resume reuses the item id,
|
||||
// so a stale teardown must not evict the newer same-id spawn. (L140/L148)
|
||||
function releaseActive(id: string, rec: ActiveDownload): void {
|
||||
if (active.get(id) === rec) active.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||
* 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)
|
||||
@@ -111,63 +110,77 @@ 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 '') wins over the persisted
|
||||
// default template — but BOTH are gated on the customCommandEnabled consent flag
|
||||
// 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[] {
|
||||
// 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({
|
||||
customCommandEnabled: settings.customCommandEnabled,
|
||||
enableCustomCommands: settings.enableCustomCommands,
|
||||
perDownloadExtraArgs: opts.extraArgs,
|
||||
defaultTemplateId: settings.defaultTemplateId,
|
||||
templates: listTemplates(),
|
||||
@@ -175,8 +188,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||
/** Resolve settings + per-download overrides into the full yt-dlp argv.
|
||||
* `cookiesFile` is the transient decrypted jar the caller materialises for the
|
||||
* 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
|
||||
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
|
||||
const settings = getSettings()
|
||||
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||
@@ -188,38 +203,49 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
|
||||
const override = opts.outputDir?.trim()
|
||||
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
||||
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||
const 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. */
|
||||
@@ -241,10 +267,7 @@ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult
|
||||
|
||||
// --- Public API -------------------------------------------------------------
|
||||
|
||||
export function startDownload(
|
||||
wc: WebContents,
|
||||
opts: StartDownloadOptions
|
||||
): StartDownloadResult {
|
||||
export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
|
||||
// Self-heal the managed copy from the bundled seed before spawning, so a
|
||||
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
|
||||
ensureManagedYtdlp()
|
||||
@@ -252,7 +275,7 @@ export function startDownload(
|
||||
if (!existsSync(ytdlp)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).`
|
||||
error: YTDLP_MISSING_MSG
|
||||
}
|
||||
}
|
||||
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
|
||||
@@ -264,11 +287,11 @@ 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,
|
||||
@@ -291,13 +314,50 @@ 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, paused: false }
|
||||
active.set(opts.id, rec)
|
||||
@@ -315,66 +375,182 @@ 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)
|
||||
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)
|
||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||
if (rec.canceled || rec.paused) return
|
||||
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
|
||||
@@ -394,10 +570,43 @@ function killTree(rec: ActiveDownload): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -412,5 +621,9 @@ export function pauseDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
rec.paused = true
|
||||
// Free the slot now (see cancelDownload / L148). The partial .part stays on disk
|
||||
// for a later resume, which reuses this id — releaseActive()'s identity check
|
||||
// keeps the doomed child's 'close' from evicting that fresh spawn. (L140)
|
||||
active.delete(id)
|
||||
killTree(rec)
|
||||
}
|
||||
|
||||
+11
-33
@@ -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 (R1–R3).
|
||||
// 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([])
|
||||
}
|
||||
|
||||
+10
-15
@@ -1,6 +1,7 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getFfmpegPath, getFfprobePath } from './binaries'
|
||||
import { execFileAsync } from './lib/exec'
|
||||
import { VERSION_TIMEOUT_MS } from './constants'
|
||||
import type { FfmpegVersionResult } from '@shared/ipc'
|
||||
|
||||
/**
|
||||
@@ -11,22 +12,16 @@ import type { FfmpegVersionResult } from '@shared/ipc'
|
||||
* out, or the line doesn't parse — Settings then shows "not found" instead of
|
||||
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
|
||||
*/
|
||||
function readToolVersion(path: string): Promise<string | null> {
|
||||
if (!existsSync(path)) return Promise.resolve(null)
|
||||
return new Promise((resolve) => {
|
||||
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
||||
const m = firstLine.match(/version\s+(\S+)/i)
|
||||
resolve(m ? m[1] : null)
|
||||
})
|
||||
})
|
||||
async function readToolVersion(path: string): Promise<string | null> {
|
||||
if (!existsSync(path)) return null
|
||||
const r = await execFileAsync(path, ['-version'], { timeout: VERSION_TIMEOUT_MS })
|
||||
if (!r.ok) return null
|
||||
const firstLine = r.stdout.split('\n', 1)[0] ?? ''
|
||||
const m = firstLine.match(/version\s+(\S+)/i)
|
||||
return m?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
|
||||
/** 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()),
|
||||
|
||||
@@ -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
@@ -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 (R1–R3).
|
||||
// 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([])
|
||||
}
|
||||
|
||||
+161
-237
@@ -1,43 +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,
|
||||
type TaskbarProgress
|
||||
} from '@shared/ipc'
|
||||
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
|
||||
import { getFfmpegVersions } from './ffmpeg'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
import { probeMedia } from './probe'
|
||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||
import { runTerminal, cancelTerminal } from './terminal'
|
||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
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
|
||||
@@ -45,54 +30,78 @@ import { createTray, markQuitting, isQuitting } from './tray'
|
||||
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
|
||||
}
|
||||
|
||||
function getSystemThemeInfo(): SystemThemeInfo {
|
||||
return {
|
||||
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
|
||||
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
|
||||
}
|
||||
// Tell the user (once per run) that closing the window left AeroFetch running so
|
||||
// an in-progress download could finish — shown only when they haven't already
|
||||
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
|
||||
// Web permissions a download manager never needs. They're denied for the app
|
||||
@@ -115,11 +124,22 @@ const DENIED_PERMISSIONS = new Set([
|
||||
])
|
||||
|
||||
function createWindow(): void {
|
||||
// Reopen where the user left off (size / position / maximized), falling back to the
|
||||
// default size when there's no usable saved state — first run, or the saved monitor
|
||||
// is no longer connected (W2 / UX19).
|
||||
const ws = initialWindowState()
|
||||
const win = new BrowserWindow({
|
||||
width: 920,
|
||||
height: 700,
|
||||
title: 'AeroFetch',
|
||||
width: ws.width,
|
||||
height: ws.height,
|
||||
x: ws.x,
|
||||
y: ws.y,
|
||||
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
||||
// floor so the window can't be dragged down to unusable widths (W1).
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
show: false,
|
||||
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
||||
backgroundColor: PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
@@ -130,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.
|
||||
@@ -137,12 +173,26 @@ function createWindow(): void {
|
||||
else win.show()
|
||||
})
|
||||
|
||||
// Minimize-to-tray: when enabled, closing the window hides it to the tray
|
||||
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
|
||||
// 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) => {
|
||||
if (getSettings().minimizeToTray && !isQuitting()) {
|
||||
// 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()
|
||||
win.hide()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -192,161 +242,12 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
||||
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
||||
downloadAppUpdate(url, e.sender)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
||||
|
||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||
|
||||
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
||||
const result = startDownload(e.sender, opts)
|
||||
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
|
||||
// download.ts's own close/error handlers, so log them here instead.
|
||||
if (!result.ok) {
|
||||
addErrorLog({
|
||||
id: opts.id,
|
||||
url: opts.url,
|
||||
kind: opts.kind,
|
||||
error: result.error ?? 'Unknown error',
|
||||
occurredAt: Date.now()
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
|
||||
runTerminal(e.sender, id, args)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||
|
||||
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||
const res = await dialog.showOpenDialog(win!, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
|
||||
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
|
||||
|
||||
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
|
||||
|
||||
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
|
||||
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
||||
const result = setSettings(partial)
|
||||
// Keep the window's native background in sync with the theme so a compositor
|
||||
// repaint never flashes a mismatched color behind an overlay. Use the
|
||||
// validated result, not the raw partial (which may hold a bogus value).
|
||||
if (partial.theme) {
|
||||
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
|
||||
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo())
|
||||
|
||||
ipcMain.handle(IpcChannels.openHighContrastSettings, () =>
|
||||
shell.openExternal('ms-settings:easeofaccess-highcontrast')
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannels.historyList, () => listHistory())
|
||||
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
||||
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
||||
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
|
||||
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
||||
|
||||
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
|
||||
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
||||
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
||||
|
||||
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
|
||||
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
|
||||
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||
previewCommand(opts)
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
|
||||
|
||||
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
|
||||
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
|
||||
|
||||
ipcMain.handle(IpcChannels.backupExport, (e) =>
|
||||
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.backupImport, async (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||
const result = await importBackup(win)
|
||||
// A restored backup may have changed the theme; keep the native window
|
||||
// background in sync the same way settingsSet does.
|
||||
if (result.ok) {
|
||||
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
|
||||
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
||||
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
||||
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
||||
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
||||
setMediaItemDownloaded(id, filePath)
|
||||
)
|
||||
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
||||
// and resolves with the final result.
|
||||
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
|
||||
indexSource(url, (p) => {
|
||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||
})
|
||||
)
|
||||
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
|
||||
const src = getSource(id)
|
||||
if (!src) return { ok: false, error: 'Source not found.' }
|
||||
return indexSource(src.url, (p) => {
|
||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||
})
|
||||
})
|
||||
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
||||
setSourceWatched(id, watched)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||
syncWatchedSources((p) => {
|
||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||
})
|
||||
)
|
||||
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
||||
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
|
||||
|
||||
// Reflect overall queue progress on the Windows taskbar (fire-and-forget).
|
||||
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (p.mode === 'none') mainWindow.setProgressBar(-1)
|
||||
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
||||
})
|
||||
}
|
||||
|
||||
// Push OS theme/contrast changes to every window, and keep the native
|
||||
// background in sync for windows currently following 'system'.
|
||||
function registerSystemThemeBridge(): void {
|
||||
nativeTheme.on('updated', () => {
|
||||
const info = getSystemThemeInfo()
|
||||
const bg = THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
|
||||
const bg = PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(IpcChannels.systemThemeUpdate, info)
|
||||
if (getSettings().theme === 'system') win.setBackgroundColor(bg)
|
||||
@@ -367,17 +268,34 @@ 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)
|
||||
|
||||
@@ -394,6 +312,12 @@ if (isPrimaryInstance) {
|
||||
}
|
||||
])
|
||||
|
||||
// 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
|
||||
@@ -402,20 +326,20 @@ if (isPrimaryInstance) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
}).catch((e) => logger.error('yt-dlp auto-update failed', e))
|
||||
})
|
||||
|
||||
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
|
||||
// the quitting flag so the window's close handler exits instead of hiding to tray.
|
||||
app.on('before-quit', () => markQuitting())
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
// Also flush any debounced JSON-store writes synchronously so a quit mid-debounce
|
||||
// can't drop the last history/sources/template change (R3).
|
||||
app.on('before-quit', () => {
|
||||
markQuitting()
|
||||
flushAllStores()
|
||||
})
|
||||
|
||||
// Windows-only app: closing the last window quits (the tray/in-flight-download
|
||||
// paths hide rather than close, so this only fires on a real exit). The former
|
||||
// macOS 'activate' handler and darwin guard were dead branches here (L147).
|
||||
app.on('window-all-closed', () => app.quit())
|
||||
}
|
||||
|
||||
+77
-35
@@ -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,9 +107,15 @@ 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 {
|
||||
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
|
||||
@@ -90,7 +123,7 @@ export async function indexSource(
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
if (!existsSync(getYtdlpPath())) {
|
||||
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' }
|
||||
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||
}
|
||||
|
||||
const cls = classifySource(url)
|
||||
@@ -109,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
|
||||
@@ -124,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 })
|
||||
}
|
||||
@@ -132,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 =
|
||||
@@ -147,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 ?? normalizedUrl)
|
||||
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'
|
||||
@@ -168,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.
|
||||
@@ -198,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
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
})
|
||||
})
|
||||
}
|
||||
+35
-43
@@ -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,52 +114,40 @@ function buildPlaylist(data: RawInfo): PlaylistInfo {
|
||||
* its format list) or, when the URL is a playlist, the flat list of its entries.
|
||||
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
|
||||
*/
|
||||
export function probeMedia(url: string): Promise<ProbeResult> {
|
||||
export async function probeMedia(url: string): Promise<ProbeResult> {
|
||||
const ytdlp = getYtdlpPath()
|
||||
if (!existsSync(ytdlp)) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
||||
})
|
||||
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||
}
|
||||
let target: string
|
||||
try {
|
||||
target = assertHttpUrl(url) // normalised form (audit F5)
|
||||
} catch (e) {
|
||||
return Promise.resolve({ ok: false, error: (e as Error).message })
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
ytdlp,
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
['-J', '--flat-playlist', '--no-warnings', '--', target],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
// execFile sets `killed` when it terminated the process on timeout.
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out fetching video info. Check the link or your connection.'
|
||||
: cleanError(stderr) || err.message
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(stdout) as RawInfo
|
||||
if (data._type === 'playlist' || Array.isArray(data.entries)) {
|
||||
const playlist = buildPlaylist(data)
|
||||
if (playlist.count === 0) {
|
||||
resolve({ ok: false, error: 'This playlist has no downloadable entries.' })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, kind: 'playlist', playlist })
|
||||
} else {
|
||||
resolve({ ok: true, kind: 'video', info: buildInfo(data) })
|
||||
}
|
||||
} catch {
|
||||
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
|
||||
}
|
||||
}
|
||||
)
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
const r = await execFileAsync(ytdlp, ['-J', '--flat-playlist', '--no-warnings', '--', target], {
|
||||
maxBuffer: PROBE_MAX_BUFFER,
|
||||
timeout: PROBE_TIMEOUT_MS
|
||||
})
|
||||
if (!r.ok) {
|
||||
const msg = r.timedOut
|
||||
? 'Timed out fetching video info. Check the link or your connection.'
|
||||
: cleanError(r.stderr) || r.error?.message || ''
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(r.stdout) as RawInfo
|
||||
if (data._type === 'playlist' || Array.isArray(data.entries)) {
|
||||
const playlist = buildPlaylist(data)
|
||||
if (playlist.count === 0) {
|
||||
return { ok: false, error: 'This playlist has no downloadable entries.' }
|
||||
}
|
||||
return { ok: true, kind: 'playlist', playlist }
|
||||
}
|
||||
return { ok: true, kind: 'video', info: buildInfo(data) }
|
||||
} catch {
|
||||
return { ok: false, error: 'Could not parse video info from yt-dlp.' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (R1–R3).
|
||||
// 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))
|
||||
}
|
||||
@@ -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
@@ -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 ''
|
||||
}
|
||||
|
||||
+22
-10
@@ -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
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { execFile } from 'child_process'
|
||||
import { getSystem32Path } from './binaries'
|
||||
import type { ScheduledSyncStatus } from '@shared/ipc'
|
||||
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. */
|
||||
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
|
||||
return new Promise((resolve) => {
|
||||
// Resolve schtasks from System32 by absolute path, not the bare name, so a
|
||||
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
|
||||
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
|
||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||
})
|
||||
execFile(
|
||||
getSystem32Path('schtasks.exe'),
|
||||
args,
|
||||
{ windowsHide: true },
|
||||
(err, stdout, stderr) => {
|
||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,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',
|
||||
@@ -52,7 +64,7 @@ export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncS
|
||||
'/SC',
|
||||
'DAILY',
|
||||
'/ST',
|
||||
'09:00',
|
||||
startTime,
|
||||
'/TN',
|
||||
TASK_NAME,
|
||||
'/TR',
|
||||
|
||||
+159
-203
@@ -1,146 +1,147 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
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,
|
||||
isYtdlpUpdateChannel,
|
||||
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 = {
|
||||
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
||||
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
||||
videoDir: '',
|
||||
audioDir: '',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
accentColor: 'teal',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
proxy: '',
|
||||
rateLimit: '',
|
||||
useAria2c: false,
|
||||
cookieSource: 'none',
|
||||
cookiesBrowser: 'chrome',
|
||||
youtubePlayerClient: '',
|
||||
youtubePoToken: '',
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false,
|
||||
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
|
||||
// nightly channel (fastest to follow YouTube changes that cause 403s).
|
||||
autoUpdateYtdlp: true,
|
||||
ytdlpChannel: 'nightly',
|
||||
ytdlpLastUpdateCheck: 0,
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: false,
|
||||
minimizeToTray: false
|
||||
}
|
||||
|
||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||
export function getDownloadArchivePath(): string {
|
||||
return join(app.getPath('userData'), 'download-archive.txt')
|
||||
}
|
||||
// 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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 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 */
|
||||
}
|
||||
export function applyLaunchAtStartup(enabled: boolean): void {
|
||||
try {
|
||||
app.setLoginItemSettings({ openAtLogin: enabled })
|
||||
} catch {
|
||||
/* non-fatal — e.g. unsupported platform */
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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),
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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, which exist only after
|
||||
// the app is ready (all callers run post-ready).
|
||||
// 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
|
||||
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||
// 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),
|
||||
@@ -154,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). */
|
||||
@@ -180,89 +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 'autoUpdateYtdlp':
|
||||
case 'customCommandEnabled':
|
||||
case 'notifyOnComplete':
|
||||
case 'autoDownloadNew':
|
||||
case 'hasCompletedOnboarding':
|
||||
case 'minimizeToTray':
|
||||
if (typeof value === 'boolean') s.set(key, value)
|
||||
break
|
||||
case 'ytdlpChannel':
|
||||
// Same allowlist that guards the `--update-to` flag (audit F1).
|
||||
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
|
||||
break
|
||||
case 'ytdlpLastUpdateCheck': {
|
||||
// Set by the main-process auto-updater; validated here since setSettings
|
||||
// is the only writer. A bogus value at worst skips/forces one check.
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n)
|
||||
break
|
||||
}
|
||||
case 'defaultTemplateId':
|
||||
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
|
||||
break
|
||||
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 'videoDir':
|
||||
case 'audioDir':
|
||||
// An empty string is allowed — it clears the override and restores the
|
||||
// Documents\Video / Documents\Audio default for that kind.
|
||||
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
||||
s.set(key, value.trim())
|
||||
}
|
||||
break
|
||||
case 'filenameTemplate':
|
||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
||||
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':
|
||||
case 'youtubePlayerClient':
|
||||
case 'youtubePoToken':
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (0–300, 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
@@ -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 (R1–R3 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 }
|
||||
}
|
||||
|
||||
+5
-4
@@ -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,7 +7,8 @@
|
||||
|
||||
import { listSources, listMediaItems } from './sources'
|
||||
import { indexSource } from './indexer'
|
||||
import { parseRssVideoIds, isYouTubeFeedUrl } 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. */
|
||||
@@ -16,7 +17,7 @@ async function fetchFeedIds(feedUrl: string): Promise<string[]> {
|
||||
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
||||
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
||||
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
||||
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
|
||||
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
|
||||
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
||||
return parseRssVideoIds(await res.text())
|
||||
}
|
||||
@@ -40,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)
|
||||
|
||||
+13
-37
@@ -1,38 +1,20 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { CommandTemplate } from '@shared/ipc'
|
||||
import { isTemplateLike } from './validation'
|
||||
|
||||
// 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 (R1–R3).
|
||||
// A persisted template entry must at least be an object carrying an id (see
|
||||
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
||||
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
||||
// malformed entries. (audit S5)
|
||||
export function listTemplates(): CommandTemplate[] {
|
||||
try {
|
||||
if (!existsSync(templatesFile())) return []
|
||||
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
|
||||
// Validate shape, then normalise each surviving entry through sanitize() so
|
||||
// name/args are always well-formed strings regardless of what was on disk.
|
||||
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX)
|
||||
|
||||
function save(templates: CommandTemplate[]): void {
|
||||
try {
|
||||
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
|
||||
} catch {
|
||||
/* best-effort; a read-only data dir just means no persisted templates */
|
||||
}
|
||||
export function listTemplates(): CommandTemplate[] {
|
||||
// Normalise each surviving entry through sanitize() so name/args are always
|
||||
// well-formed strings regardless of what was on disk.
|
||||
return store.read().map(sanitize)
|
||||
}
|
||||
|
||||
function sanitize(t: CommandTemplate): CommandTemplate {
|
||||
@@ -49,20 +31,14 @@ function sanitize(t: CommandTemplate): CommandTemplate {
|
||||
/** Add a new template, or update an existing one (matched by id). */
|
||||
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
|
||||
const clean = sanitize(template)
|
||||
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
|
||||
save(templates)
|
||||
return templates
|
||||
return store.write([clean, ...listTemplates().filter((t) => t.id !== clean.id)])
|
||||
}
|
||||
|
||||
export function removeTemplate(id: string): CommandTemplate[] {
|
||||
const templates = listTemplates().filter((t) => t.id !== id)
|
||||
save(templates)
|
||||
return templates
|
||||
return store.write(listTemplates().filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
/** Replace the entire template list (backup restore) rather than merge-by-id. */
|
||||
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
|
||||
const clean = templates.map(sanitize)
|
||||
save(clean)
|
||||
return clean
|
||||
return store.write(templates.map(sanitize))
|
||||
}
|
||||
|
||||
+20
-17
@@ -3,7 +3,7 @@
|
||||
* arguments and streams stdout/stderr back to the renderer line-by-line.
|
||||
*
|
||||
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
|
||||
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
|
||||
* 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.
|
||||
*/
|
||||
@@ -13,7 +13,9 @@ import { type WebContents } from 'electron'
|
||||
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
|
||||
import { getSettings } from './settings'
|
||||
import { ensureManagedYtdlp } from './ytdlp'
|
||||
import { parseExtraArgs } from './buildArgs'
|
||||
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>()
|
||||
@@ -23,7 +25,7 @@ function send(wc: WebContents, ev: TerminalEvent): void {
|
||||
}
|
||||
|
||||
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
|
||||
if (!getSettings().customCommandEnabled) {
|
||||
if (!getSettings().enableCustomCommands) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
|
||||
@@ -35,6 +37,11 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
|
||||
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)]
|
||||
@@ -45,36 +52,32 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
|
||||
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 buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
|
||||
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
|
||||
buffers[stream] += chunk
|
||||
let nl: number
|
||||
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
|
||||
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
|
||||
buffers[stream] = buffers[stream].slice(nl + 1)
|
||||
send(wc, { type: 'output', id, line, stream })
|
||||
}
|
||||
const lineBufs = {
|
||||
stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })),
|
||||
stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' }))
|
||||
}
|
||||
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
|
||||
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
|
||||
child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString()))
|
||||
child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
|
||||
|
||||
let settled = false
|
||||
child.on('error', (err) => {
|
||||
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.
|
||||
for (const stream of ['stdout', 'stderr'] as const) {
|
||||
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
|
||||
}
|
||||
lineBufs.stdout.flush()
|
||||
lineBufs.stderr.flush()
|
||||
send(wc, { type: 'done', id, code })
|
||||
})
|
||||
|
||||
|
||||
+14
-3
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
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
|
||||
@@ -31,9 +32,19 @@ function show(getWindow: () => BrowserWindow | null): void {
|
||||
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
|
||||
export function createTray(getWindow: () => BrowserWindow | null): void {
|
||||
if (tray) return
|
||||
const icon = nativeImage.createFromPath(getAppIconPath())
|
||||
// Without a usable icon a Windows tray entry is invisible/unclickable, which is
|
||||
// worse than no tray — so skip it rather than ship a dead tray.
|
||||
// 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)
|
||||
|
||||
@@ -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='
|
||||
]
|
||||
]
|
||||
+115
-159
@@ -1,28 +1,15 @@
|
||||
import { app, net, shell, type WebContents } from 'electron'
|
||||
import { createWriteStream, type WriteStream } from 'fs'
|
||||
import { stat, unlink } from 'fs/promises'
|
||||
import { stat } from 'fs/promises'
|
||||
import { join, normalize, dirname } from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import {
|
||||
IpcChannels,
|
||||
type AppUpdateInfo,
|
||||
type AppUpdateDownload,
|
||||
type AppUpdateProgress
|
||||
} from '@shared/ipc'
|
||||
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. The updater reads
|
||||
// the repo's latest release over the public REST API and downloads the installer
|
||||
// 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.
|
||||
//
|
||||
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
|
||||
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
|
||||
// AeroFetch never ships a token; on a sign-in-required instance the check simply
|
||||
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
|
||||
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
|
||||
const UPDATE_OWNER = 'debont80'
|
||||
const UPDATE_REPO = 'AeroFetch'
|
||||
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
||||
|
||||
// Security: only ever download or execute a file served by the trusted update
|
||||
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
|
||||
@@ -51,9 +38,10 @@ function updateDir(): string {
|
||||
* an "upgrade" over the same shipped version.
|
||||
*/
|
||||
function parseVersion(v: string): number[] {
|
||||
return v
|
||||
.replace(/^v/i, '')
|
||||
.split(/[-+]/)[0]
|
||||
// split() always yields at least one element; `?? ''` only satisfies the type
|
||||
// checker (noUncheckedIndexedAccess) for the [0] access.
|
||||
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
|
||||
return core
|
||||
.split('.')
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
@@ -88,9 +76,28 @@ interface GiteaRelease {
|
||||
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||
* longer run like a sha512 digest is ignored rather than sliced).
|
||||
*
|
||||
* When `fileName` is given (the installer asset's name), a multi-line / combined
|
||||
* checksum file is matched line-by-line and the hash on the line naming THIS
|
||||
* asset wins — so a `<asset>.sha256` that happens to list several files can't
|
||||
* verify the installer against the wrong line's hash (B3). Falls back to the
|
||||
* first standalone digest for bare single-hash files (no filename present).
|
||||
*/
|
||||
export function extractSha256(text: string): string | null {
|
||||
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
||||
export function extractSha256(text: string, fileName?: string): string | null {
|
||||
const hashRe = /\b[a-f0-9]{64}\b/i
|
||||
if (fileName) {
|
||||
const base = fileName.toLowerCase()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(hashRe)
|
||||
if (!m) continue
|
||||
// The filename is the last whitespace-delimited token on a sha256sum /
|
||||
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
|
||||
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
|
||||
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
|
||||
if (last.toLowerCase() === base) return m[0].toLowerCase()
|
||||
}
|
||||
}
|
||||
const m = text.match(hashRe)
|
||||
return m ? m[0].toLowerCase() : null
|
||||
}
|
||||
|
||||
@@ -110,6 +117,10 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
||||
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) {
|
||||
@@ -155,8 +166,18 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the download may stall (no bytes received) before we give up. */
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
|
||||
// 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
|
||||
@@ -179,7 +200,9 @@ function fetchTrustedText(
|
||||
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
||||
const done = (
|
||||
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
|
||||
): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
@@ -218,7 +241,7 @@ function fetchTrustedText(
|
||||
}
|
||||
|
||||
/** Stream the installer to a temp file, pushing progress events to the renderer. */
|
||||
export async function downloadAppUpdate(url: string, wc: WebContents): Promise<AppUpdateDownload> {
|
||||
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
|
||||
if (!isTrustedDownloadUrl(url)) {
|
||||
return { ok: false, error: 'Refused to download from an untrusted location.' }
|
||||
}
|
||||
@@ -236,141 +259,69 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
||||
u.pathname += '.sha256'
|
||||
return u.toString()
|
||||
})()
|
||||
const sum = await fetchTrustedText(checksumUrl)
|
||||
let expectedSha: string | null = null
|
||||
if (sum.ok) {
|
||||
expectedSha = extractSha256(sum.text)
|
||||
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||
} else if (sum.status === 404) {
|
||||
if (REQUIRE_CHECKSUM) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
|
||||
// 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
|
||||
}
|
||||
|
||||
return new Promise<AppUpdateDownload>((resolve) => {
|
||||
let settled = false
|
||||
let fileStream: WriteStream | null = null
|
||||
let idle: NodeJS.Timeout | null = null
|
||||
|
||||
// Single teardown point. On failure it also aborts the request, so a write
|
||||
// error (disk full, etc.) can't leave Electron pulling bytes into a dead
|
||||
// stream. abort() is idempotent, so the call sites below don't repeat it.
|
||||
const finish = (result: AppUpdateDownload): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (idle) clearTimeout(idle)
|
||||
if (!result.ok) {
|
||||
request.abort()
|
||||
// Close the handle before unlinking (Windows won't delete an open file)
|
||||
// and never leave a partial/aborted installer lying around in temp.
|
||||
if (fileStream && !fileStream.destroyed) fileStream.destroy()
|
||||
unlink(filePath).catch(() => {})
|
||||
}
|
||||
resolve(result)
|
||||
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." })
|
||||
}
|
||||
|
||||
// net.request (not fetch) so we can re-validate the host on EVERY redirect
|
||||
// hop: Gitea 302s a release download to its attachment store, and undici's
|
||||
// fetch hides the Location header under redirect:'manual', so it can't gate
|
||||
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
|
||||
const armIdle = (): void => {
|
||||
if (idle) clearTimeout(idle)
|
||||
idle = setTimeout(() => {
|
||||
finish({ ok: false, error: 'Download stalled — please try again.' })
|
||||
}, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||
} 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}.` })
|
||||
}
|
||||
|
||||
request.on('redirect', (_status, _method, redirectUrl) => {
|
||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||
finish({ ok: false, error: 'Refused to follow a redirect to an untrusted location.' })
|
||||
return
|
||||
}
|
||||
request.followRedirect()
|
||||
})
|
||||
|
||||
request.on('response', (response) => {
|
||||
const status = response.statusCode
|
||||
if (status < 200 || status >= 300) {
|
||||
finish({ ok: false, error: `Download failed (HTTP ${status}).` })
|
||||
return
|
||||
}
|
||||
|
||||
const lenHeader = response.headers['content-length']
|
||||
const total = Number(Array.isArray(lenHeader) ? lenHeader[0] : lenHeader) || undefined
|
||||
|
||||
const stream = createWriteStream(filePath)
|
||||
fileStream = stream
|
||||
// Hash the bytes as they stream by, so verification needs no second pass.
|
||||
const hash = createHash('sha256')
|
||||
// Electron's IncomingMessage is event-based (no .pipe). pause()/resume()
|
||||
// exist at runtime but aren't in its type, so feature-detect them to apply
|
||||
// backpressure — without it a ~300 MB installer can buffer in memory.
|
||||
const flow = response as unknown as { pause?(): void; resume?(): void }
|
||||
let received = 0
|
||||
armIdle()
|
||||
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
armIdle()
|
||||
received += chunk.length
|
||||
hash.update(chunk)
|
||||
if (!stream.write(chunk) && flow.pause && flow.resume) {
|
||||
flow.pause()
|
||||
stream.once('drain', () => flow.resume?.())
|
||||
}
|
||||
if (!wc.isDestroyed()) {
|
||||
const progress: AppUpdateProgress = {
|
||||
received,
|
||||
total,
|
||||
fraction: total ? received / total : undefined
|
||||
}
|
||||
wc.send(IpcChannels.appUpdateProgress, progress)
|
||||
}
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
// Body fully received — "stalled" is no longer meaningful past this point.
|
||||
if (idle) clearTimeout(idle)
|
||||
stream.end(() => {
|
||||
// A truncated download (connection dropped mid-stream) must never be run.
|
||||
if (total !== undefined && received !== total) {
|
||||
finish({ ok: false, error: 'The download was incomplete — please try again.' })
|
||||
return
|
||||
}
|
||||
// Verify the bytes we wrote match the release's published SHA-256.
|
||||
if (expectedSha && hash.digest('hex') !== expectedSha) {
|
||||
finish({
|
||||
ok: false,
|
||||
error:
|
||||
'The downloaded update failed its checksum check — it may be corrupt or tampered with. Nothing was installed.'
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({ ok: true, filePath })
|
||||
})
|
||||
})
|
||||
|
||||
// A mid-stream abort emits neither 'end' nor always 'error'; catch it so the
|
||||
// promise can't hang.
|
||||
response.on('aborted', () => finish({ ok: false, error: 'The download was interrupted.' }))
|
||||
response.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
stream.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
})
|
||||
|
||||
request.on('error', (e: Error) => finish({ ok: false, error: e.message }))
|
||||
request.end()
|
||||
// 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.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Let the launched installer spawn before we quit to release our files (ms). */
|
||||
const INSTALLER_HANDOFF_MS = 1500
|
||||
|
||||
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
|
||||
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
@@ -383,10 +334,15 @@ export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; err
|
||||
return { ok: false, error: 'Refused to run an unexpected file.' }
|
||||
}
|
||||
await stat(target) // throws if the file is missing
|
||||
const err = await shell.openPath(target) // hand the installer to the OS
|
||||
// Hand the installer to the OS via ShellExecute, which (unlike a raw
|
||||
// CreateProcess/spawn) honors the NSIS elevation manifest — needed if the
|
||||
// build ever flips to perMachine. openPath resolves once the launch is
|
||||
// initiated, so the installer process already exists when we quit.
|
||||
const err = await shell.openPath(target)
|
||||
if (err) return { ok: false, error: err }
|
||||
// Give the installer a beat to spawn, then quit so it can overwrite our files.
|
||||
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
|
||||
// Quit on the next tick so this IPC response reaches the renderer first; the
|
||||
// launched installer is independent of our process and survives the quit.
|
||||
setImmediate(() => app.quit())
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+57
-47
@@ -1,9 +1,12 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
|
||||
import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||
import { execFileAsync } from './lib/exec'
|
||||
import { cleanError } from './log'
|
||||
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
|
||||
import { getSettings, setSettings } from './settings'
|
||||
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
||||
import { shouldAutoCheckYtdlp } from './core/ytdlpPolicy'
|
||||
import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock'
|
||||
import {
|
||||
isYtdlpUpdateChannel,
|
||||
type YtdlpVersionResult,
|
||||
@@ -32,32 +35,24 @@ export function ensureManagedYtdlp(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
|
||||
* Proves the main-process → bundled-binary → IPC path end to end.
|
||||
*/
|
||||
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
/** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
|
||||
export async function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
const ytdlpPath = getYtdlpPath()
|
||||
|
||||
if (!existsSync(ytdlpPath)) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
|
||||
})
|
||||
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out running yt-dlp.'
|
||||
: (stderr || err.message).trim()
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, version: stdout.trim() })
|
||||
})
|
||||
})
|
||||
const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS })
|
||||
if (!r.ok) {
|
||||
// Run stderr through the shared cleaner so yt-dlp's noisy output collapses to
|
||||
// its trailing error line, consistent with download/probe/indexer (CC6).
|
||||
const msg = r.timedOut
|
||||
? 'Timed out running yt-dlp.'
|
||||
: cleanError(r.stderr) || r.error?.message || ''
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
return { ok: true, version: r.stdout.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,13 +61,13 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
* yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and
|
||||
* a per-user install; it would fail under a locked-down system install.
|
||||
*/
|
||||
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
|
||||
export async function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
|
||||
// Validate against the channel allowlist BEFORE the value reaches `--update-to`.
|
||||
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an
|
||||
// arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a
|
||||
// compromised renderer over IPC) must never be forwarded. (audit F1)
|
||||
if (!isYtdlpUpdateChannel(channel)) {
|
||||
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
|
||||
return { ok: false, error: 'Unsupported update channel.' }
|
||||
}
|
||||
|
||||
// Self-heal: restore the managed copy from the bundled seed before updating, so
|
||||
@@ -81,29 +76,34 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
|
||||
const ytdlpPath = getYtdlpPath()
|
||||
|
||||
if (!existsSync(ytdlpPath)) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
|
||||
})
|
||||
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
ytdlpPath,
|
||||
['--update-to', channel],
|
||||
{ windowsHide: true, timeout: 60_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out updating yt-dlp.'
|
||||
: (stderr || err.message).trim()
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, output: stdout.trim() })
|
||||
}
|
||||
)
|
||||
})
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,11 +125,21 @@ export async function runStartupYtdlpAutoUpdate(
|
||||
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() })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+107
-31
@@ -9,6 +9,9 @@ import {
|
||||
type YtdlpUpdateResult,
|
||||
type YtdlpAutoUpdateStatus,
|
||||
type FfmpegVersionResult,
|
||||
type FfmpegSetupStatus,
|
||||
type FfmpegSetupProgress,
|
||||
type FfmpegSetupResult,
|
||||
type ProbeResult,
|
||||
type StartDownloadOptions,
|
||||
type StartDownloadResult,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
type CookiesStatus,
|
||||
type CookiesLoginResult,
|
||||
type CommandTemplate,
|
||||
type MediaProfile,
|
||||
type CommandPreviewResult,
|
||||
type ErrorLogEntry,
|
||||
type BackupExportResult,
|
||||
@@ -31,7 +35,8 @@ import {
|
||||
type ScheduledSyncStatus,
|
||||
type TerminalEvent,
|
||||
type TerminalRunResult,
|
||||
type TaskbarProgress
|
||||
type TaskbarProgress,
|
||||
type PersistedQueueItem
|
||||
} from '@shared/ipc'
|
||||
|
||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||
@@ -46,6 +51,9 @@ const api = {
|
||||
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),
|
||||
@@ -57,35 +65,61 @@ const api = {
|
||||
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
|
||||
},
|
||||
|
||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||
|
||||
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
|
||||
/** 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),
|
||||
|
||||
pauseDownload: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||
pauseDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||
|
||||
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
||||
|
||||
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
||||
chooseFolder: (current?: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IpcChannels.chooseFolder, current),
|
||||
|
||||
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
|
||||
|
||||
showInFolder: (path: string): Promise<void> =>
|
||||
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
|
||||
|
||||
showInFolder: (path: string): Promise<string> =>
|
||||
ipcRenderer.invoke(IpcChannels.showInFolder, path),
|
||||
|
||||
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
|
||||
|
||||
/** Forward a renderer-side failure to the main diagnostic log (CC8). Fire-and-forget. */
|
||||
logError: (op: string, detail: string): void =>
|
||||
ipcRenderer.send(IpcChannels.logWrite, op, detail),
|
||||
|
||||
getSettings: (): Promise<Settings> => ipcRenderer.invoke(IpcChannels.settingsGet),
|
||||
|
||||
setSettings: (partial: Partial<Settings>): Promise<Settings> =>
|
||||
@@ -120,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),
|
||||
@@ -138,6 +180,13 @@ const api = {
|
||||
|
||||
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
|
||||
|
||||
/** Load the persisted download queue on launch (M4). */
|
||||
listQueue: (): Promise<PersistedQueueItem[]> => ipcRenderer.invoke(IpcChannels.queueList),
|
||||
|
||||
/** Mirror the renderer's durable queue items to disk so they survive a quit (M4). */
|
||||
saveQueue: (items: PersistedQueueItem[]): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.queueSave, items),
|
||||
|
||||
/** Opens a save dialog and writes settings + templates to the chosen JSON file. */
|
||||
exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport),
|
||||
|
||||
@@ -172,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),
|
||||
|
||||
@@ -184,28 +233,44 @@ 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) => {
|
||||
@@ -220,8 +285,7 @@ const api = {
|
||||
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
|
||||
|
||||
cancelTerminal: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
|
||||
cancelTerminal: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.terminalCancel, id),
|
||||
|
||||
/** Subscribe to live terminal output. Returns an unsubscribe function. */
|
||||
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
|
||||
@@ -230,9 +294,13 @@ const api = {
|
||||
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
|
||||
},
|
||||
|
||||
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */
|
||||
setTaskbarProgress: (p: TaskbarProgress): void =>
|
||||
ipcRenderer.send(IpcChannels.taskbarProgress, p)
|
||||
/** Reflect overall queue progress on the Windows taskbar. */
|
||||
setTaskbarProgress: (p: TaskbarProgress): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannels.taskbarProgress, p),
|
||||
|
||||
/** Open a YouTube WebView to automatically extract a PO token (Phase P). */
|
||||
mintPoToken: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IpcChannels.youtubePoTokenMint) as Promise<string | null>
|
||||
}
|
||||
|
||||
export type Api = typeof api
|
||||
@@ -248,11 +316,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
|
||||
}
|
||||
|
||||
+191
-54
@@ -1,18 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
|
||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
import { LibraryView } from './components/LibraryView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
import { TerminalView } from './components/TerminalView'
|
||||
import { SettingsView } from './components/SettingsView'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { DownloadsView } from './views/DownloadsView'
|
||||
import { Onboarding } from './views/Onboarding'
|
||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { LiveRegion } from './components/LiveRegion'
|
||||
import { Toaster } from './components/ui/Toaster'
|
||||
import { getTheme, pageBackground } from './lib/theme'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useSetup } from './store/setup'
|
||||
import { useNav } from './store/nav'
|
||||
import { useDownloads } from './store/downloads'
|
||||
import { summarizeQueue } from './store/queueStats'
|
||||
// Eagerly load the sources store for its startup side-effects (load persisted
|
||||
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
|
||||
// subscription (coordinator's downloadCompleted → markDownloaded). Before C2 this
|
||||
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
|
||||
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
|
||||
import './store/sources'
|
||||
import { queueSummaryOf } from './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: {
|
||||
@@ -26,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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -36,16 +70,30 @@ function App(): React.JSX.Element {
|
||||
const theme = useSettings((s) => s.theme)
|
||||
const accentColor = useSettings((s) => s.accentColor)
|
||||
const updateSettings = useSettings((s) => s.update)
|
||||
const showTerminal = useSettings((s) => s.enableCustomCommands)
|
||||
const isDark = useResolvedDark()
|
||||
const [tab, setTab] = useState<TabValue>('downloads')
|
||||
const tab = useNav((s) => s.tab)
|
||||
const setTab = useNav((s) => s.setTab)
|
||||
// Active-download count for the sidebar Downloads badge (UX9/UI25), so a run in
|
||||
// progress is visible from any tab. A number selector only re-renders when the
|
||||
// count actually changes -- not on every progress tick.
|
||||
const activeCount = useDownloads(
|
||||
(s) => s.items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
||||
)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
// Track the element that was focused before the palette opened so we can restore it on close.
|
||||
const prePaletteRef = useRef<Element | null>(null)
|
||||
|
||||
// Ctrl/Cmd+K toggles the command palette.
|
||||
// Ctrl+K: command palette. Ctrl+,: Settings. (W8/W9)
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault()
|
||||
prePaletteRef.current = document.activeElement
|
||||
setPaletteOpen((o) => !o)
|
||||
} else if (e.ctrlKey && e.key === ',') {
|
||||
e.preventDefault()
|
||||
setTab('settings')
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
@@ -54,62 +102,138 @@ function App(): React.JSX.Element {
|
||||
|
||||
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
|
||||
// directly (not via a selector) so taskbar updates don't re-render the app.
|
||||
// Compare against the last IPC call and skip when nothing changed -- the store
|
||||
// fires on every progress tick and this avoids one IPC roundtrip per tick (L25).
|
||||
useEffect(() => {
|
||||
let lastFraction = -1
|
||||
let lastMode = ''
|
||||
let lastBadge = -1
|
||||
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||
const s = summarizeQueue(items)
|
||||
const s = queueSummaryOf(items)
|
||||
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
|
||||
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
|
||||
if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return
|
||||
lastFraction = s.progress
|
||||
lastMode = mode
|
||||
lastBadge = s.downloading
|
||||
void window.api?.setTaskbarProgress?.({
|
||||
fraction: s.progress,
|
||||
mode,
|
||||
badgeCount: s.downloading
|
||||
})
|
||||
}
|
||||
push(useDownloads.getState().items)
|
||||
return useDownloads.subscribe((st) => push(st.items))
|
||||
}, [])
|
||||
|
||||
const paletteActions: PaletteAction[] = [
|
||||
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
|
||||
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
|
||||
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
|
||||
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
|
||||
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
|
||||
{
|
||||
id: 'new-download',
|
||||
label: 'New download',
|
||||
hint: 'Focus URL',
|
||||
run: () => {
|
||||
setTab('downloads')
|
||||
// Wait for the download bar to mount after the tab switch, then focus.
|
||||
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
|
||||
// Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
|
||||
// other pending items return from the previous session.
|
||||
useEffect(() => {
|
||||
useDownloads.getState().hydrate()
|
||||
}, [])
|
||||
|
||||
// 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()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'toggle-theme',
|
||||
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
|
||||
hint: 'Appearance',
|
||||
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
|
||||
}
|
||||
]
|
||||
}, SCHEDULE_TICK_MS)
|
||||
return () => clearInterval(tick)
|
||||
}, [])
|
||||
|
||||
// Memoized so CommandPalette sees a stable array reference between renders --
|
||||
// only rebuilds when the theme label needs to change (L32).
|
||||
const paletteActions = useMemo<PaletteAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'go-downloads',
|
||||
label: 'Go to Downloads',
|
||||
hint: 'Navigate',
|
||||
run: () => setTab('downloads')
|
||||
},
|
||||
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
|
||||
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
|
||||
{
|
||||
id: 'go-terminal',
|
||||
label: 'Go to Terminal',
|
||||
hint: 'Navigate',
|
||||
run: () => setTab('terminal')
|
||||
},
|
||||
{
|
||||
id: 'go-settings',
|
||||
label: 'Go to Settings',
|
||||
// Surface the real shortcut here so the palette doubles as shortcut
|
||||
// discovery (UX21).
|
||||
hint: 'Ctrl ,',
|
||||
run: () => setTab('settings')
|
||||
},
|
||||
{
|
||||
id: 'new-download',
|
||||
label: 'New download',
|
||||
hint: 'Focus URL',
|
||||
run: () => {
|
||||
setTab('downloads')
|
||||
// Double-rAF waits for React's re-render + paint (so the DownloadBar has
|
||||
// mounted and registered its focuser) before requesting focus (L118).
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => useNav.getState().focusUrlField())
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'toggle-theme',
|
||||
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
|
||||
hint: 'Appearance',
|
||||
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
|
||||
}
|
||||
],
|
||||
[isDark, setTab, updateSettings]
|
||||
)
|
||||
|
||||
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||
const [version, setVersion] = useState('')
|
||||
useEffect(() => {
|
||||
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
|
||||
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
|
||||
}, [])
|
||||
|
||||
// Sidebar collapse, persisted across launches in localStorage.
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
|
||||
)
|
||||
const collapsed = useSettings((s) => s.isSidebarCollapsed)
|
||||
function toggleCollapsed(): void {
|
||||
setCollapsed((c) => {
|
||||
const next = !c
|
||||
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
|
||||
return next
|
||||
})
|
||||
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
|
||||
@@ -136,21 +260,34 @@ function App(): React.JSX.Element {
|
||||
version={version}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={toggleCollapsed}
|
||||
showTerminal={showTerminal}
|
||||
downloadCount={activeCount}
|
||||
/>
|
||||
|
||||
<main className={styles.content}>
|
||||
{tab === 'downloads' && <DownloadsView />}
|
||||
{tab === 'library' && <LibraryView />}
|
||||
{tab === 'history' && <HistoryView />}
|
||||
{tab === 'terminal' && <TerminalView />}
|
||||
{tab === 'settings' && <SettingsView />}
|
||||
<Suspense fallback={null}>
|
||||
{tab === 'library' && <LibraryView />}
|
||||
{tab === 'history' && <HistoryView />}
|
||||
{tab === 'terminal' && <TerminalView />}
|
||||
{tab === 'settings' && <SettingsView />}
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{paletteOpen && (
|
||||
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
|
||||
<CommandPalette
|
||||
actions={paletteActions}
|
||||
onClose={() => {
|
||||
setPaletteOpen(false)
|
||||
const el = prePaletteRef.current
|
||||
if (el instanceof HTMLElement) requestAnimationFrame(() => el.focus())
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<LiveRegion />
|
||||
<Toaster />
|
||||
</div>
|
||||
</FluentProvider>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
|
||||
|
||||
export interface PaletteAction {
|
||||
id: string
|
||||
@@ -10,32 +12,31 @@ export interface PaletteAction {
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
|
||||
// A plain fixed overlay -- NOT a Fluent Dialog, since this app avoids Fluent's
|
||||
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
|
||||
backdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
zIndex: Z.overlay,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
paddingTop: '14vh',
|
||||
backgroundColor: 'rgba(0,0,0,0.32)'
|
||||
backgroundColor: SCRIM
|
||||
},
|
||||
panel: {
|
||||
width: 'min(560px, 92vw)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
||||
...shorthands.borderRadius(RADIUS.card),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
boxShadow: tokens.shadow28,
|
||||
boxShadow: ELEVATION.overlay,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
input: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '14px 16px',
|
||||
fontSize: tokens.fontSizeBase400,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
@@ -91,11 +92,16 @@ export function CommandPalette({
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const [q, setQ] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||
const listId = 'cmdpalette-list'
|
||||
const optionId = (id: string): string => `cmdpalette-option-${id}`
|
||||
const activeId = filtered[sel] ? optionId(filtered[sel].id) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
@@ -103,6 +109,10 @@ export function CommandPalette({
|
||||
useEffect(() => {
|
||||
setSel(0)
|
||||
}, [q])
|
||||
useEffect(() => {
|
||||
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -133,21 +143,33 @@ export function CommandPalette({
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
className={mergeClasses(styles.input, focus.focusRing)}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Type a command…"
|
||||
role="combobox"
|
||||
aria-expanded={filtered.length > 0}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId}
|
||||
aria-autocomplete="list"
|
||||
aria-label="Command palette search"
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
<div className={styles.list} ref={listRef} id={listId} role="listbox" aria-label="Commands">
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.empty}>No matching commands</div>
|
||||
) : (
|
||||
filtered.map((a, i) => (
|
||||
<button
|
||||
// A non-focusable option: focus stays on the combobox input and the
|
||||
// active row is announced via aria-activedescendant (UI27), the
|
||||
// standard combobox+listbox pattern. onMouseEnter keeps the pointer
|
||||
// and keyboard highlight unified on one `sel` state.
|
||||
<div
|
||||
key={a.id}
|
||||
type="button"
|
||||
id={optionId(a.id)}
|
||||
role="option"
|
||||
aria-selected={i === sel}
|
||||
data-sel={i === sel ? 'true' : undefined}
|
||||
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||
onClick={() => {
|
||||
a.run()
|
||||
@@ -157,7 +179,7 @@ export function CommandPalette({
|
||||
>
|
||||
<span>{a.label}</span>
|
||||
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
Switch,
|
||||
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
|
||||
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
|
||||
sponsor: 'Sponsor',
|
||||
intro: 'Intro / intermission',
|
||||
outro: 'Endcards / credits',
|
||||
outro: 'End cards / credits',
|
||||
selfpromo: 'Self-promotion',
|
||||
preview: 'Preview / recap',
|
||||
filler: 'Filler / tangent',
|
||||
@@ -83,6 +83,8 @@ const useStyles = makeStyles({
|
||||
interface Props {
|
||||
value: DownloadOptions
|
||||
onChange: (next: DownloadOptions) => void
|
||||
/** When provided, hides controls that don't apply to this media kind. */
|
||||
kind?: 'video' | 'audio'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +92,7 @@ interface Props {
|
||||
* Used both for the persisted defaults (Settings) and for a per-download
|
||||
* override (the download bar), so the two never drift apart.
|
||||
*/
|
||||
export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Element {
|
||||
export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
|
||||
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
|
||||
@@ -107,35 +109,47 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.grid}>
|
||||
<Field label="Audio format" hint="For audio-only downloads.">
|
||||
<Select
|
||||
aria-label="Audio format"
|
||||
value={value.audioFormat}
|
||||
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
|
||||
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Video container" hint="Container for merged video.">
|
||||
<Select
|
||||
aria-label="Video container"
|
||||
value={value.videoContainer}
|
||||
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
|
||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
|
||||
<Select
|
||||
aria-label="Preferred video codec"
|
||||
value={value.preferredVideoCodec}
|
||||
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
|
||||
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
|
||||
/>
|
||||
</Field>
|
||||
{kind !== 'video' && (
|
||||
<Field label="Audio format" hint="For audio-only downloads.">
|
||||
<Select
|
||||
value={value.audioFormat}
|
||||
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
|
||||
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{kind !== 'audio' && (
|
||||
<Field
|
||||
label="Video container"
|
||||
hint="The file format used when video and audio are combined into one file."
|
||||
>
|
||||
<Select
|
||||
value={value.videoContainer}
|
||||
options={VIDEO_CONTAINERS.map((c) => ({
|
||||
value: c,
|
||||
label: VIDEO_CONTAINER_LABELS[c]
|
||||
}))}
|
||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{kind !== 'audio' && (
|
||||
<Field
|
||||
label="Preferred codec"
|
||||
hint="A preference when several formats match -- not a strict filter."
|
||||
>
|
||||
<Select
|
||||
value={value.preferredVideoCodec}
|
||||
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
|
||||
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Format sorting (advanced)"
|
||||
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
|
||||
hint="Custom expression to rank available formats, e.g. res:1080,vcodec:av01,size. Leave blank to use the Preferred codec setting above. See yt-dlp docs for the full syntax."
|
||||
>
|
||||
<Input
|
||||
value={value.formatSort}
|
||||
@@ -144,11 +158,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
||||
<Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
|
||||
label={value.embedSubtitles ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
{value.embedSubtitles && (
|
||||
@@ -177,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
<Switch
|
||||
checked={value.sponsorBlock}
|
||||
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
|
||||
label={value.sponsorBlock ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
{value.sponsorBlock && (
|
||||
<div className={styles.subGroup}>
|
||||
<Field label="Action">
|
||||
<Select
|
||||
aria-label="SponsorBlock action"
|
||||
value={value.sponsorBlockMode}
|
||||
options={[
|
||||
{ value: 'remove', label: 'Remove segments (cut from file)' },
|
||||
@@ -193,7 +204,15 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Categories">
|
||||
<Field
|
||||
label="Categories"
|
||||
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
|
||||
validationMessage={
|
||||
value.sponsorBlockCategories.length === 0
|
||||
? 'No categories selected -- no segments will be skipped.'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.categoryGrid}>
|
||||
{SPONSORBLOCK_CATEGORIES.map((cat) => (
|
||||
<Checkbox
|
||||
@@ -208,11 +227,13 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Embed chapters">
|
||||
<Field
|
||||
label="Embed chapters"
|
||||
hint="Write chapter markers into the file. Only useful if the video has chapters."
|
||||
>
|
||||
<Switch
|
||||
checked={value.embedChapters}
|
||||
onChange={(_, d) => setOpt('embedChapters', d.checked)}
|
||||
label={value.embedChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
@@ -222,21 +243,40 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
<Switch
|
||||
checked={value.splitChapters}
|
||||
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
||||
label={value.splitChapters ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||
<Switch
|
||||
checked={value.embedMetadata}
|
||||
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
|
||||
label={value.embedMetadata ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Metadata overrides"
|
||||
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
|
||||
>
|
||||
<div className={styles.subGroup}>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={value.metadataTitle ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataTitle', d.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Artist"
|
||||
value={value.metadataArtist ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataArtist', d.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Album"
|
||||
value={value.metadataAlbum ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
|
||||
<Switch
|
||||
checked={value.embedThumbnail}
|
||||
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
|
||||
label={value.embedThumbnail ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
{value.embedThumbnail && (
|
||||
@@ -254,7 +294,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
|
||||
<Field
|
||||
label="Sidecar files"
|
||||
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
|
||||
hint="Write separate metadata/poster/description files next to each download -- handy for Jellyfin, Plex or Kodi libraries."
|
||||
>
|
||||
<div className={styles.subGroup}>
|
||||
<Checkbox
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Button,
|
||||
ProgressBar,
|
||||
makeStyles,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
||||
import { useDownloads } from '../store/downloads'
|
||||
import { summarizeQueue } from '../store/queueStats'
|
||||
import { DownloadBar } from './DownloadBar'
|
||||
import { QueueItem } from './QueueItem'
|
||||
import { VirtualList } from './VirtualList'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
// Fill the scroll area so the queue list below can flex to the remaining
|
||||
// height and scroll internally (it's virtualized), instead of the whole page
|
||||
// growing to thousands of rows.
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
height: '100%'
|
||||
},
|
||||
queueHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
headerActions: {
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
},
|
||||
summary: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '10px 14px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
summaryBar: {
|
||||
flexGrow: 1
|
||||
},
|
||||
summaryText: {
|
||||
flexShrink: 0,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
listScroll: {
|
||||
flexGrow: 1,
|
||||
// min-height:0 lets this flex child shrink below its content height so it,
|
||||
// not the page, owns the scrolling.
|
||||
minHeight: 0
|
||||
},
|
||||
empty: {
|
||||
display: 'flex',
|
||||
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 retryAll = useDownloads((s) => s.retryAll)
|
||||
|
||||
const summary = summarizeQueue(items)
|
||||
const hasFinished = items.some(
|
||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<DownloadBar />
|
||||
|
||||
{summary.active && (
|
||||
<div className={styles.summary}>
|
||||
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
|
||||
<Caption1 className={styles.summaryText}>
|
||||
{summary.downloading} downloading
|
||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||
{summary.speedLabel ? ` • ${summary.speedLabel}` : ''}
|
||||
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.queueHeader}>
|
||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
||||
<div className={styles.headerActions}>
|
||||
{summary.failed > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={retryAll}
|
||||
>
|
||||
Retry all failed ({summary.failed})
|
||||
</Button>
|
||||
)}
|
||||
{hasFinished && (
|
||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||
Clear finished
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<ArrowDownloadRegular fontSize={40} />
|
||||
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
||||
</div>
|
||||
) : (
|
||||
<VirtualList
|
||||
items={items}
|
||||
className={styles.listScroll}
|
||||
gap={10}
|
||||
overscan={6}
|
||||
estimateSize={() => 100}
|
||||
getKey={(item) => item.id}
|
||||
renderItem={(item) => <QueueItem item={item} />}
|
||||
/>
|
||||
)}
|
||||
</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 couldn’t 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { Z } from './ui/tokens'
|
||||
|
||||
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a
|
||||
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
|
||||
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
|
||||
},
|
||||
bubble: {
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
zIndex: Z.tooltip,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
|
||||
alignEnd: { right: 0 }
|
||||
})
|
||||
|
||||
interface HintProps {
|
||||
// `align` only shifts a top/bottom bubble left/right; it does nothing for a
|
||||
// left/right placement. The union makes that explicit — passing `align` with
|
||||
// `placement="left"|"right"` is a type error now, instead of being silently
|
||||
// ignored (L129).
|
||||
type HintProps = {
|
||||
label: string
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
align?: 'start' | 'end'
|
||||
children: React.ReactNode
|
||||
}
|
||||
} & (
|
||||
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
|
||||
| { placement: 'left' | 'right'; align?: never }
|
||||
)
|
||||
|
||||
export function Hint({
|
||||
label,
|
||||
|
||||
@@ -1,349 +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,
|
||||
HistoryRegular,
|
||||
SearchRegular,
|
||||
ArrowClockwiseRegular,
|
||||
CheckmarkSquareRegular,
|
||||
DismissRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
||||
import { useHistory } from '../store/history'
|
||||
import { useResolvedDark } from '../store/systemTheme'
|
||||
import { useDownloads } from '../store/downloads'
|
||||
import { thumbColors } from '../theme'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { Hint } from './Hint'
|
||||
import { Select } from './Select'
|
||||
|
||||
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 = useResolvedDark()
|
||||
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) => {
|
||||
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}`}
|
||||
/>
|
||||
)}
|
||||
<MediaThumb
|
||||
className={styles.thumb}
|
||||
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
|
||||
kind={h.kind}
|
||||
iconSize={22}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<Text className={styles.title}>{h.title}</Text>
|
||||
<Caption1 className={styles.meta}>
|
||||
{[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>
|
||||
)
|
||||
}
|
||||
@@ -1,719 +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 } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { VirtualList } from './VirtualList'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
||||
type ItemStatus = DownloadStatus | 'pending'
|
||||
|
||||
/**
|
||||
* A flattened row for the virtualized item list: either a playlist header or a
|
||||
* single video. Flattening the groups into one list lets a 1000s-video source
|
||||
* window cleanly (one virtualizer over a flat array, headers included).
|
||||
*/
|
||||
type LibRow =
|
||||
| { kind: 'header'; title: string; items: MediaItem[] }
|
||||
| { kind: 'item'; item: MediaItem }
|
||||
|
||||
/** Above this many flattened rows, the item list switches to a virtualized panel. */
|
||||
const VIRTUALIZE_AT = 100
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
paused: 'Paused',
|
||||
saved: 'Saved',
|
||||
completed: 'Downloaded',
|
||||
error: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
|
||||
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 },
|
||||
groupHead: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 4px',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
||||
},
|
||||
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '5px 4px 5px 18px'
|
||||
},
|
||||
plainList: { display: 'flex', flexDirection: 'column' },
|
||||
rowThumb: {
|
||||
width: '60px',
|
||||
height: '34px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusSmall)
|
||||
},
|
||||
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||
rowTitle: {
|
||||
whiteSpace: 'nowrap',
|
||||
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 [batchNote, setBatchNote] = useState<string | null>(null)
|
||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||
// Which playlist groups are expanded. Empty = all collapsed (the default), so
|
||||
// an expanded source shows just its group headers until one is opened.
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
const [scheduled, setScheduled] = useState(false)
|
||||
|
||||
const watchedCount = sources.filter((s) => s.watched).length
|
||||
|
||||
// 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 (and any batch note) whenever the expanded source changes.
|
||||
useEffect(() => {
|
||||
setSelected(new Set())
|
||||
setBatchNote(null)
|
||||
setExpandedGroups(new Set())
|
||||
}, [selectedSourceId])
|
||||
|
||||
// Live per-URL queue status so a video row reflects its real download state.
|
||||
const statusByUrl = useMemo(() => {
|
||||
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])
|
||||
// A group is shown when toggled open, or auto-expanded when it's the only group
|
||||
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
|
||||
const isGroupOpen = (title: string): boolean =>
|
||||
groups.length === 1 || expandedGroups.has(title)
|
||||
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
|
||||
const flatRows = useMemo<LibRow[]>(() => {
|
||||
const rows: LibRow[] = []
|
||||
for (const g of groups) {
|
||||
rows.push({ kind: 'header', title: g.title, items: g.items })
|
||||
if (groups.length === 1 || expandedGroups.has(g.title)) {
|
||||
for (const it of g.items) rows.push({ kind: 'item', item: it })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}, [groups, expandedGroups])
|
||||
|
||||
const effStatus = (it: MediaItem): ItemStatus =>
|
||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
||||
|
||||
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
|
||||
|
||||
// An item is worth (re)queuing when it isn't already downloaded or in flight:
|
||||
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
|
||||
// downloading items keeps "Select all → Download" from enqueuing duplicates,
|
||||
// since addFromUrl doesn't dedupe by URL.
|
||||
const actionable = (it: MediaItem): boolean => {
|
||||
const st = effStatus(it)
|
||||
return st === 'pending' || st === 'error' || st === 'canceled'
|
||||
}
|
||||
const actionableItems = items.filter(actionable)
|
||||
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
|
||||
const allActionableSelected =
|
||||
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
|
||||
|
||||
async function onIndex(): Promise<void> {
|
||||
setError(null)
|
||||
const u = url.trim()
|
||||
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 toggleGroupExpand(title: string): void {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(title)) next.delete(title)
|
||||
else next.add(title)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const it of groupItems) {
|
||||
if (on) next.add(it.id)
|
||||
else next.delete(it.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Select / clear every downloadable item across all groups in this source.
|
||||
function toggleAll(on: boolean): void {
|
||||
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
|
||||
}
|
||||
|
||||
// One click queues every chosen item — maxConcurrent gates how many actually
|
||||
// run, the rest wait in the queue. Selection clears since nothing is held back.
|
||||
function downloadSelected(): void {
|
||||
if (!selectedSourceId || selectedActionable.length === 0) return
|
||||
const n = enqueueItems(selectedSourceId, selectedActionable)
|
||||
setSelected(new Set())
|
||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
function downloadPending(): void {
|
||||
if (!selectedSourceId || pendingItems.length === 0) return
|
||||
const n = enqueueItems(selectedSourceId, pendingItems)
|
||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
// Queue every actionable (pending/failed/canceled) video in one playlist group,
|
||||
// so "download this whole playlist" is a single click.
|
||||
function downloadGroup(groupItems: MediaItem[]): void {
|
||||
if (!selectedSourceId) return
|
||||
const toQueue = groupItems.filter(actionable)
|
||||
if (toQueue.length === 0) return
|
||||
const n = enqueueItems(selectedSourceId, toQueue)
|
||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
function pillClass(status: ItemStatus): string {
|
||||
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
||||
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
|
||||
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
|
||||
return styles.pill
|
||||
}
|
||||
|
||||
// One row of the item list — a playlist header or a video — shared by the
|
||||
// inline (small source) and virtualized (large source) render paths.
|
||||
function rowKey(row: LibRow): string {
|
||||
return row.kind === 'header' ? `h:${row.title}` : row.item.id
|
||||
}
|
||||
|
||||
function renderRow(row: LibRow): React.JSX.Element {
|
||||
if (row.kind === 'header') {
|
||||
const allOn = row.items.every((it) => selected.has(it.id))
|
||||
const open = isGroupOpen(row.title)
|
||||
const groupActionable = row.items.filter(actionable).length
|
||||
return (
|
||||
<div
|
||||
className={styles.groupHead}
|
||||
onClick={() => toggleGroupExpand(row.title)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
|
||||
}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<AppsListRegular />
|
||||
<span className={styles.groupTitle}>{row.title}</span>
|
||||
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleGroup(row.items, !allOn)
|
||||
}}
|
||||
>
|
||||
{allOn ? 'None' : 'All'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
disabled={groupActionable === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
downloadGroup(row.items)
|
||||
}}
|
||||
>
|
||||
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const it = row.item
|
||||
const status = effStatus(it)
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Checkbox
|
||||
checked={selected.has(it.id)}
|
||||
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
||||
aria-label={`Select ${it.title}`}
|
||||
/>
|
||||
<MediaThumb
|
||||
className={styles.rowThumb}
|
||||
src={thumbUrl({ url: it.url, videoId: it.videoId })}
|
||||
kind="video"
|
||||
iconSize={16}
|
||||
/>
|
||||
<div className={styles.rowMain}>
|
||||
<span className={styles.rowTitle}>
|
||||
{it.playlistIndex}. {it.title}
|
||||
</span>
|
||||
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
|
||||
</div>
|
||||
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<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}>
|
||||
<Checkbox
|
||||
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
|
||||
onChange={(_, d) => toggleAll(!!d.checked)}
|
||||
label="Select all"
|
||||
disabled={actionableItems.length === 0}
|
||||
/>
|
||||
<Caption1 className={styles.srcSub}>
|
||||
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
||||
{relTime(src.lastIndexedAt)}
|
||||
</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}
|
||||
disabled={selectedActionable.length === 0}
|
||||
>
|
||||
Download {selectedActionable.length} selected
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={downloadPending}
|
||||
disabled={pendingItems.length === 0}
|
||||
>
|
||||
Download {pendingItems.length} 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>
|
||||
|
||||
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
|
||||
|
||||
{flatRows.length > VIRTUALIZE_AT ? (
|
||||
// Big source: a fixed-height, internally-scrolling virtualized
|
||||
// panel so 1000s of rows stay light. A definite height (not
|
||||
// max-height) gives the virtualizer a viewport to measure.
|
||||
<VirtualList
|
||||
items={flatRows}
|
||||
style={{ height: '58vh' }}
|
||||
overscan={10}
|
||||
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
|
||||
getKey={(row) => rowKey(row)}
|
||||
renderItem={(row) => renderRow(row)}
|
||||
/>
|
||||
) : (
|
||||
// Small source: render inline so the card grows naturally.
|
||||
<div className={styles.plainList}>
|
||||
{flatRows.map((row) => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { makeStyles, mergeClasses } from '@fluentui/react-components'
|
||||
import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
|
||||
import type { MediaKind } from '@shared/ipc'
|
||||
import { useResolvedDark } from '../store/systemTheme'
|
||||
import { thumbColors } from '../theme'
|
||||
import { thumbColors } from '../lib/theme'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
box: {
|
||||
@@ -41,7 +41,7 @@ export function MediaThumb({
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const isDark = useResolvedDark()
|
||||
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
|
||||
const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
||||
const showImg = !!src && failedSrc !== src
|
||||
|
||||
@@ -53,13 +53,7 @@ export function MediaThumb({
|
||||
style={{ backgroundColor: colors.bg, color: colors.fg }}
|
||||
>
|
||||
{showImg ? (
|
||||
<img
|
||||
className={styles.img}
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailedSrc(src ?? null)}
|
||||
/>
|
||||
<img className={styles.img} src={src} alt="" onError={() => setFailedSrc(src ?? null)} />
|
||||
) : kind === 'audio' ? (
|
||||
<MusicNote2Regular fontSize={iconSize} />
|
||||
) : (
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
Title2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Field,
|
||||
Button,
|
||||
makeStyles,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
ArrowDownloadFilled,
|
||||
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'
|
||||
},
|
||||
folderNote: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
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 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's nothing else to install.
|
||||
</Body1>
|
||||
|
||||
<Field label="Where downloads go">
|
||||
<Caption1 className={styles.folderNote}>
|
||||
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time
|
||||
in Settings.
|
||||
</Caption1>
|
||||
</Field>
|
||||
|
||||
<div className={styles.tips}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
Text,
|
||||
Caption1,
|
||||
Button,
|
||||
ProgressBar,
|
||||
Badge,
|
||||
Spinner,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
@@ -25,28 +25,38 @@ import {
|
||||
ErrorCircleFilled,
|
||||
EyeOffRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
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,
|
||||
@@ -60,20 +70,11 @@ const useStyles = makeStyles({
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
title: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
meta: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
progressRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginTop: '4px'
|
||||
marginTop: SPACE.xtight
|
||||
},
|
||||
progressBar: {
|
||||
flexGrow: 1
|
||||
@@ -88,63 +89,88 @@ const useStyles = makeStyles({
|
||||
actions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
flexShrink: 0
|
||||
gap: SPACE.xtight,
|
||||
flexShrink: 0,
|
||||
// Icon-only row actions get a >=40px hit target for touch/pen (W16); the
|
||||
// glyph stays its normal size, the subtle button just carries more padding.
|
||||
'& button': {
|
||||
minWidth: '40px',
|
||||
minHeight: '40px'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
||||
queued: { label: 'Queued', color: 'subtle' },
|
||||
downloading: { label: 'Downloading', color: 'brand' },
|
||||
paused: { label: 'Paused', color: 'warning' },
|
||||
saved: { label: 'Saved', color: 'subtle' },
|
||||
completed: { label: 'Completed', color: 'success' },
|
||||
error: { label: 'Failed', color: 'danger' },
|
||||
canceled: { label: 'Canceled', color: 'warning' }
|
||||
}
|
||||
|
||||
function pct(progress: number): string {
|
||||
return `${Math.round(progress * 100)}%`
|
||||
}
|
||||
|
||||
function fmtSchedule(ms: number): string {
|
||||
try {
|
||||
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const QueueItem = memo(function QueueItem({
|
||||
item
|
||||
}: {
|
||||
item: DownloadItem
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const cancel = useDownloads((s) => s.cancel)
|
||||
const pause = useDownloads((s) => s.pause)
|
||||
const resume = useDownloads((s) => s.resume)
|
||||
const prioritize = useDownloads((s) => s.prioritize)
|
||||
const saveForLater = useDownloads((s) => s.saveForLater)
|
||||
const queueNow = useDownloads((s) => s.queueNow)
|
||||
const remove = useDownloads((s) => s.remove)
|
||||
const retry = useDownloads((s) => s.retry)
|
||||
const openFile = useDownloads((s) => s.openFile)
|
||||
const showInFolder = useDownloads((s) => s.showInFolder)
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
// One shallow-compared selection instead of 10 separate subscriptions per row
|
||||
// (L163). The actions are stable, so this never re-renders from the store.
|
||||
const {
|
||||
cancel,
|
||||
pause,
|
||||
resume,
|
||||
prioritize,
|
||||
saveForLater,
|
||||
queueNow,
|
||||
remove,
|
||||
retry,
|
||||
openFile,
|
||||
showInFolder
|
||||
} = useDownloads(
|
||||
useShallow((s) => ({
|
||||
cancel: s.cancel,
|
||||
pause: s.pause,
|
||||
resume: s.resume,
|
||||
prioritize: s.prioritize,
|
||||
saveForLater: s.saveForLater,
|
||||
queueNow: s.queueNow,
|
||||
remove: s.remove,
|
||||
retry: s.retry,
|
||||
openFile: s.openFile,
|
||||
showInFolder: s.showInFolder
|
||||
}))
|
||||
)
|
||||
|
||||
const badge = STATUS_BADGE[item.status]
|
||||
const active = item.status === 'downloading' || item.status === 'queued'
|
||||
|
||||
// W7: Delete on a focused row removes it -- cancelling first if it's still running,
|
||||
// since an in-flight download can't just be dropped from the list. Only when the
|
||||
// row itself holds focus, so Delete on one of its action buttons is unaffected.
|
||||
function onKeyDown(e: React.KeyboardEvent): void {
|
||||
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||
e.preventDefault()
|
||||
if (active) cancel(item.id)
|
||||
else remove(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Format the raw progress numbers for display (H4: main sends bytes/sec + secs,
|
||||
// the renderer owns formatting via the one shared formatter).
|
||||
const speedLabel = fmtSpeed(item.speedBytesPerSec)
|
||||
const etaLabel = fmtEta(item.etaSeconds)
|
||||
|
||||
// L55: a probed-format quality label already carries the size ("720p · mp4 ·
|
||||
// 184 MB"); skip sizeLabel when it's already embedded to avoid showing it twice.
|
||||
const sizeAlreadyInQuality = item.sizeLabel && item.quality.includes(item.sizeLabel)
|
||||
const metaParts = [
|
||||
item.channel,
|
||||
item.durationLabel,
|
||||
item.quality,
|
||||
item.kind === 'audio' ? 'Audio' : 'Video',
|
||||
item.sizeLabel
|
||||
!sizeAlreadyInQuality ? item.sizeLabel : undefined
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
|
||||
<MediaThumb
|
||||
className={styles.thumb}
|
||||
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
|
||||
@@ -156,57 +182,82 @@ export const QueueItem = memo(function QueueItem({
|
||||
<div className={styles.titleRow}>
|
||||
{item.status === 'completed' && (
|
||||
<CheckmarkCircleFilled
|
||||
fontSize={16}
|
||||
fontSize={ICON.inline}
|
||||
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<ErrorCircleFilled
|
||||
fontSize={16}
|
||||
fontSize={ICON.inline}
|
||||
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={styles.title}>{item.title}</Text>
|
||||
<Badge appearance="tint" color={badge.color}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
<Text className={text.title}>{item.title}</Text>
|
||||
<StatusChip status={item.status} />
|
||||
{item.incognito && (
|
||||
<Hint label="Private — not saved to history" placement="top" align="start">
|
||||
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
||||
<Hint label="Private -- not saved to history" placement="top" align="start">
|
||||
<EyeOffRegular
|
||||
fontSize={ICON.inline}
|
||||
style={{ color: tokens.colorNeutralForeground3 }}
|
||||
/>
|
||||
</Hint>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
|
||||
<Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
|
||||
|
||||
{item.status === 'downloading' && (
|
||||
<div className={styles.progressRow}>
|
||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
||||
{/* Indeterminate when finishing (the second stream / merge reads as
|
||||
"working" rather than a 0→100% restart, SR7) or when yt-dlp can't
|
||||
report a total size, so the bar never sits frozen at 0% (L137). */}
|
||||
<ProgressBar
|
||||
className={styles.progressBar}
|
||||
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
||||
thickness="large"
|
||||
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||
/>
|
||||
<Caption1 className={styles.stats}>
|
||||
{pct(item.progress)}
|
||||
{item.speed ? ` • ${item.speed}` : ''}
|
||||
{item.eta ? ` • ${item.eta} left` : ''}
|
||||
{item.finishing ? (
|
||||
'Finishing…'
|
||||
) : (
|
||||
<>
|
||||
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
|
||||
{speedLabel ? `${META_SEP}${speedLabel}` : ''}
|
||||
{etaLabel ? `${META_SEP}${etaLabel} left` : ''}
|
||||
</>
|
||||
)}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'queued' && (
|
||||
<div className={styles.progressRow}>
|
||||
<Spinner size="extra-tiny" />
|
||||
<Spinner size="tiny" />
|
||||
<Caption1 className={styles.stats}>Waiting to start…</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'paused' && (
|
||||
<div className={styles.progressRow}>
|
||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
||||
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
||||
<ProgressBar
|
||||
className={styles.progressBar}
|
||||
value={item.progress}
|
||||
thickness="large"
|
||||
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||
/>
|
||||
<Caption1 className={styles.stats}>
|
||||
Paused{META_SEP}
|
||||
{pct(item.progress)}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Caption1 className={styles.stats}>
|
||||
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
||||
{item.scheduledFor
|
||||
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
|
||||
: 'Saved for later'}
|
||||
</Caption1>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import {
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands,
|
||||
useFieldControlProps_unstable
|
||||
} from '@fluentui/react-components'
|
||||
|
||||
// A native <select> styled to match Fluent inputs. We use this instead of
|
||||
// Fluent's <Dropdown> because Fluent's portal/popover menu is a composited
|
||||
// overlay in the WebContents, and on this dev machine's GPU/driver that overlay
|
||||
// paint blanks the whole window (same family as the documented flicker —
|
||||
// paint blanks the whole window (same family as the documented flicker --
|
||||
// hardware acceleration is already disabled; see memory "aerofetch-gpu-flicker").
|
||||
// The native <select> popup is drawn by the OS, so it can't trigger the blank.
|
||||
// The popup's light/dark styling follows the `color-scheme` set on the app root
|
||||
@@ -27,6 +33,20 @@ const useStyles = makeStyles({
|
||||
outline: 'none',
|
||||
...shorthands.borderColor(tokens.colorCompoundBrandStroke)
|
||||
}
|
||||
},
|
||||
large: {
|
||||
height: '40px',
|
||||
padding: '0 12px',
|
||||
fontSize: tokens.fontSizeBase400
|
||||
},
|
||||
disabled: {
|
||||
cursor: 'not-allowed',
|
||||
color: tokens.colorNeutralForegroundDisabled,
|
||||
backgroundColor: tokens.colorNeutralBackgroundDisabled,
|
||||
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled),
|
||||
':hover': {
|
||||
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,6 +61,8 @@ interface SelectProps {
|
||||
onChange: (value: string) => void
|
||||
className?: string
|
||||
'aria-label'?: string
|
||||
size?: 'medium' | 'large'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -48,15 +70,30 @@ export function Select({
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
'aria-label': ariaLabel
|
||||
'aria-label': ariaLabel,
|
||||
size,
|
||||
disabled
|
||||
}: SelectProps): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
// Integrate with a wrapping Fluent <Field> (L133): pick up the id + label/hint
|
||||
// association so the Field's visible label names the select natively (via
|
||||
// `htmlFor`), instead of every call site repeating the name in an `aria-label`.
|
||||
// Outside a Field this returns the props unchanged, so standalone selects keep
|
||||
// their own `aria-label`. `supportsLabelFor` = native <label for>, not aria-labelledby.
|
||||
const fieldProps = useFieldControlProps_unstable({}, { supportsLabelFor: true })
|
||||
return (
|
||||
<select
|
||||
className={mergeClasses(styles.select, className)}
|
||||
{...fieldProps}
|
||||
aria-label={ariaLabel}
|
||||
className={mergeClasses(
|
||||
styles.select,
|
||||
size === 'large' && styles.large,
|
||||
disabled && styles.disabled,
|
||||
className
|
||||
)}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,11 @@
|
||||
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import {
|
||||
Caption1,
|
||||
CounterBadge,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
ArrowDownloadFilled,
|
||||
ArrowDownloadRegular,
|
||||
@@ -14,6 +21,11 @@ import {
|
||||
} from '@fluentui/react-icons'
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { IconButton } from './ui/IconButton'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
import { useTextStyles } from './ui/text'
|
||||
import { MOTION, ICON } from './ui/tokens'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||
|
||||
@@ -27,7 +39,8 @@ const useStyles = makeStyles({
|
||||
padding: '16px 12px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
transition: 'width 0.15s ease'
|
||||
// The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
|
||||
transition: `width ${MOTION.duration} ${MOTION.curve}`
|
||||
},
|
||||
rootCollapsed: {
|
||||
width: '60px',
|
||||
@@ -41,24 +54,6 @@ const useStyles = makeStyles({
|
||||
topBarCollapsed: {
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconBtn: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
}
|
||||
},
|
||||
brand: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -79,7 +74,8 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px'
|
||||
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||
fontSize: `${ICON.control}px`
|
||||
},
|
||||
brandText: {
|
||||
display: 'flex',
|
||||
@@ -92,9 +88,6 @@ const useStyles = makeStyles({
|
||||
lineHeight: tokens.lineHeightBase400,
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
caption: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
nav: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -102,6 +95,8 @@ const useStyles = makeStyles({
|
||||
alignSelf: 'stretch'
|
||||
},
|
||||
navItem: {
|
||||
// relative so the collapsed count badge can pin to the icon's corner.
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '11px',
|
||||
@@ -119,6 +114,16 @@ const useStyles = makeStyles({
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
// Expanded: the active-download count sits at the far right of the nav row.
|
||||
navBadge: {
|
||||
marginLeft: 'auto'
|
||||
},
|
||||
// Collapsed: pin the count to the top-right corner of the centered icon.
|
||||
navBadgeCollapsed: {
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '8px'
|
||||
},
|
||||
navItemCollapsed: {
|
||||
justifyContent: 'center',
|
||||
padding: '9px 0'
|
||||
@@ -127,52 +132,21 @@ const useStyles = makeStyles({
|
||||
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',
|
||||
fontSize: `${ICON.control}px`,
|
||||
flexShrink: 0,
|
||||
display: 'flex'
|
||||
},
|
||||
spacer: {
|
||||
flexGrow: 1
|
||||
},
|
||||
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
||||
themeGroup: {
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
overflow: 'hidden'
|
||||
},
|
||||
themeSeg: {
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '5px',
|
||||
padding: '7px 4px',
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
themeSegActive: {
|
||||
backgroundColor: tokens.colorBrandBackground,
|
||||
color: tokens.colorNeutralForegroundOnBrand,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorBrandBackgroundHover
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -202,6 +176,10 @@ interface SidebarProps {
|
||||
version: string
|
||||
collapsed: boolean
|
||||
onToggleCollapsed: () => void
|
||||
/** Show the Terminal nav item (only when custom commands are enabled) */
|
||||
showTerminal: boolean
|
||||
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
|
||||
downloadCount: number
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -212,22 +190,27 @@ export function Sidebar({
|
||||
onSetTheme,
|
||||
version,
|
||||
collapsed,
|
||||
onToggleCollapsed
|
||||
onToggleCollapsed,
|
||||
showTerminal,
|
||||
downloadCount
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const text = useTextStyles()
|
||||
|
||||
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
||||
const order: ThemeMode[] = ['light', 'dark', 'system']
|
||||
function cycleTheme(): void {
|
||||
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
|
||||
const next = order[(order.indexOf(theme) + 1) % order.length]
|
||||
if (next) onSetTheme(next)
|
||||
}
|
||||
const themeIcon =
|
||||
theme === 'system' ? (
|
||||
<DesktopRegular fontSize={18} />
|
||||
<DesktopRegular fontSize={ICON.control} />
|
||||
) : isDark ? (
|
||||
<WeatherMoonRegular fontSize={18} />
|
||||
<WeatherMoonRegular fontSize={ICON.control} />
|
||||
) : (
|
||||
<WeatherSunnyRegular fontSize={18} />
|
||||
<WeatherSunnyRegular fontSize={ICON.control} />
|
||||
)
|
||||
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
|
||||
|
||||
@@ -235,15 +218,12 @@ export function Sidebar({
|
||||
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
||||
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
|
||||
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
<IconButton
|
||||
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-pressed={collapsed}
|
||||
>
|
||||
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||
</button>
|
||||
/>
|
||||
</Hint>
|
||||
</div>
|
||||
|
||||
@@ -254,13 +234,18 @@ export function Sidebar({
|
||||
{!collapsed && (
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandName}>AeroFetch</span>
|
||||
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
|
||||
{/* Stable tagline so the caption never flips from text to a version
|
||||
string once it loads (L66); the version shows on hover and in
|
||||
Settings → About. */}
|
||||
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
|
||||
Video downloader
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.nav}>
|
||||
{NAV.map((n) => {
|
||||
{NAV.filter((n) => n.value !== 'terminal' || showTerminal).map((n) => {
|
||||
const active = tab === n.value
|
||||
const btn = (
|
||||
<button
|
||||
@@ -269,19 +254,28 @@ export function Sidebar({
|
||||
className={mergeClasses(
|
||||
styles.navItem,
|
||||
collapsed && styles.navItemCollapsed,
|
||||
active && styles.navItemActive
|
||||
active && styles.navItemActive,
|
||||
focus.focusRing
|
||||
)}
|
||||
style={
|
||||
active && !collapsed
|
||||
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onTabChange(n.value)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={n.label}
|
||||
aria-label={
|
||||
n.value === 'downloads' && downloadCount > 0
|
||||
? `${n.label}, ${downloadCount} active`
|
||||
: n.label
|
||||
}
|
||||
>
|
||||
<span className={styles.navIcon}>{n.icon}</span>
|
||||
{!collapsed && n.label}
|
||||
{n.value === 'downloads' && downloadCount > 0 && (
|
||||
<CounterBadge
|
||||
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
|
||||
count={downloadCount}
|
||||
size="small"
|
||||
color="brand"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
return collapsed ? (
|
||||
@@ -298,34 +292,20 @@ export function Sidebar({
|
||||
|
||||
{collapsed ? (
|
||||
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
<IconButton
|
||||
icon={themeIcon}
|
||||
onClick={cycleTheme}
|
||||
aria-label={`Theme: ${themeLabel}. Click to change.`}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
aria-label={`Change theme (currently ${themeLabel})`}
|
||||
/>
|
||||
</Hint>
|
||||
) : (
|
||||
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
|
||||
{THEMES.map((t) => {
|
||||
const on = theme === t.value
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
|
||||
onClick={() => onSetTheme(t.value)}
|
||||
>
|
||||
<span className={styles.navIcon}>{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SegmentedControl<ThemeMode>
|
||||
fitted
|
||||
value={theme}
|
||||
options={THEMES}
|
||||
onChange={onSetTheme}
|
||||
ariaLabel="Theme"
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
|
||||
@@ -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 & 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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,10 +88,6 @@ interface Draft {
|
||||
}
|
||||
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline (no-modal — see the Hint/Select comments re: composited overlays
|
||||
* flickering on this dev machine's GPU) CRUD list + add/edit form for
|
||||
@@ -100,13 +105,24 @@ export function TemplateManager(): React.JSX.Element {
|
||||
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
|
||||
}
|
||||
|
||||
function patternError(): string | undefined {
|
||||
const p = draft?.urlPattern.trim()
|
||||
if (!p) return undefined
|
||||
try {
|
||||
new RegExp(p)
|
||||
return undefined
|
||||
} catch {
|
||||
return 'Invalid regex — this pattern will never match.'
|
||||
}
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
if (!draft) return
|
||||
const name = draft.name.trim()
|
||||
if (!name) return
|
||||
if (!name || patternError()) return
|
||||
const urlPattern = draft.urlPattern.trim()
|
||||
save({
|
||||
id: draft.id ?? newId(),
|
||||
id: draft.id ?? newId('tpl'),
|
||||
name,
|
||||
args: draft.args.trim(),
|
||||
...(urlPattern ? { urlPattern } : {})
|
||||
@@ -117,7 +133,7 @@ export function TemplateManager(): React.JSX.Element {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{templates.length === 0 && !draft && (
|
||||
<Caption1 className={styles.empty}>No custom command templates yet.</Caption1>
|
||||
<EmptyState compact message="No custom command templates yet." />
|
||||
)}
|
||||
|
||||
{templates.map((t) => (
|
||||
@@ -162,11 +178,16 @@ export function TemplateManager(): React.JSX.Element {
|
||||
resize="vertical"
|
||||
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.urlPattern}
|
||||
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
|
||||
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
|
||||
/>
|
||||
<Field
|
||||
validationState={patternError() ? 'error' : 'none'}
|
||||
validationMessage={patternError()}
|
||||
>
|
||||
<Input
|
||||
value={draft.urlPattern}
|
||||
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
|
||||
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.formActions}>
|
||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||
Cancel
|
||||
@@ -182,7 +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>
|
||||
)}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Textarea,
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
makeStyles,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../store/settings'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
interface Line {
|
||||
text: string
|
||||
kind: LineKind
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
gate: {
|
||||
padding: '12px 14px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
color: tokens.colorStatusWarningForeground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
|
||||
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
|
||||
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
|
||||
buttons: { display: 'flex', gap: '8px' },
|
||||
log: {
|
||||
flexGrow: 1,
|
||||
minHeight: '160px',
|
||||
overflowY: 'auto',
|
||||
margin: 0,
|
||||
padding: '12px 14px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
fontFamily: tokens.fontFamilyMonospace,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
lineHeight: tokens.lineHeightBase300,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
|
||||
stderr: { color: tokens.colorPaletteRedForeground1 },
|
||||
sys: { color: tokens.colorNeutralForeground3 },
|
||||
empty: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
||||
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
||||
* flag (enforced again in main — see src/main/terminal.ts).
|
||||
*/
|
||||
export function TerminalView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||
|
||||
const [args, setArgs] = useState('')
|
||||
const [lines, setLines] = useState<Line[]>([])
|
||||
const [running, setRunning] = useState(false)
|
||||
const runId = useRef<string | null>(null)
|
||||
const logRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
// Stream terminal output for the current run into the log.
|
||||
useEffect(
|
||||
() =>
|
||||
window.api.onTerminalOutput((ev) => {
|
||||
if (ev.id !== runId.current) return
|
||||
if (ev.type === 'output') {
|
||||
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
|
||||
} else if (ev.type === 'error') {
|
||||
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
} else {
|
||||
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
// Keep the log pinned to the latest output.
|
||||
useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
||||
}, [lines])
|
||||
|
||||
function run(): void {
|
||||
const a = args.trim()
|
||||
if (!a || running) return
|
||||
const id = newId()
|
||||
runId.current = id
|
||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
||||
setRunning(true)
|
||||
window.api
|
||||
.runTerminal(id, a)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
|
||||
setRunning(false)
|
||||
runId.current = null
|
||||
})
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (runId.current) window.api.cancelTerminal(runId.current)
|
||||
}
|
||||
|
||||
const lineClass = (kind: LineKind): string | undefined =>
|
||||
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Terminal</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||
</Caption1>
|
||||
</div>
|
||||
|
||||
{!customCommandEnabled && (
|
||||
<Body1 className={styles.gate}>
|
||||
The terminal is part of custom commands. Turn on “Run custom commands” in Settings →
|
||||
Custom commands to use it.
|
||||
</Body1>
|
||||
)}
|
||||
|
||||
<div className={styles.inputRow}>
|
||||
<div className={styles.inputCol}>
|
||||
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||
<Textarea
|
||||
value={args}
|
||||
onChange={(_, d) => setArgs(d.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
run()
|
||||
}
|
||||
}}
|
||||
placeholder="--version (Ctrl+Enter to run)"
|
||||
resize="vertical"
|
||||
disabled={!customCommandEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
{running ? (
|
||||
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<PlayRegular />}
|
||||
onClick={run}
|
||||
disabled={!customCommandEnabled || !args.trim()}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setLines([])}
|
||||
disabled={lines.length === 0}
|
||||
aria-label="Clear output"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className={styles.log} ref={logRef}>
|
||||
{lines.length === 0 ? (
|
||||
<span className={styles.empty}>Output will appear here.</span>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className={lineClass(l.kind)}>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,28 +42,35 @@ export function VirtualList<T>({
|
||||
estimateSize,
|
||||
overscan,
|
||||
gap,
|
||||
getItemKey: (index) => getKey(items[index], index)
|
||||
getItemKey: (index) => {
|
||||
const it = items[index]
|
||||
return it !== undefined ? getKey(it, index) : index
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
{virtualizer.getVirtualItems().map((vrow) => (
|
||||
<div
|
||||
key={vrow.key}
|
||||
data-index={vrow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vrow.start}px)`
|
||||
}}
|
||||
>
|
||||
{renderItem(items[vrow.index], vrow.index)}
|
||||
</div>
|
||||
))}
|
||||
{virtualizer.getVirtualItems().map((vrow) => {
|
||||
const item = items[vrow.index]
|
||||
if (item === undefined) return null
|
||||
return (
|
||||
<div
|
||||
key={vrow.key}
|
||||
data-index={vrow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vrow.start}px)`
|
||||
}}
|
||||
>
|
||||
{renderItem(item, vrow.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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}`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,728 @@
|
||||
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
import {
|
||||
parseUrlShortcutContent,
|
||||
BEST_FORMAT_ID,
|
||||
type MediaInfo,
|
||||
type FormatOption,
|
||||
type PlaylistInfo,
|
||||
type DownloadOptions,
|
||||
DEFAULT_DOWNLOAD_OPTIONS,
|
||||
type CommandPreviewResult
|
||||
} from '@shared/ipc'
|
||||
import { useDownloads, type MediaKind } from '../../store/downloads'
|
||||
import { type ChosenFormat } from '../../store/downloadTypes'
|
||||
import { buildPlaylistAddEntries } from './playlistEntries'
|
||||
import { useHistory } from '../../store/history'
|
||||
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
|
||||
import { sameVideo } from '../../lib/queueStats'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useNav } from '../../store/nav'
|
||||
import {
|
||||
useClipboardLink,
|
||||
looksLikeUrl,
|
||||
looksLikeChannelOrPlaylist,
|
||||
firstUrlInText,
|
||||
type SuggestionSource
|
||||
} from '../../lib/useClipboardLink'
|
||||
import { logError } from '../../reportError'
|
||||
|
||||
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
|
||||
function parseUrlFile(content: string): string | null {
|
||||
const url = parseUrlShortcutContent(content)
|
||||
return url && looksLikeUrl(url) ? url : null
|
||||
}
|
||||
|
||||
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
|
||||
// in LOCAL time -- used as the picker's `min` so a past time can't be chosen and
|
||||
// then silently download immediately (L156/L57).
|
||||
export function toLocalDatetimeValue(d: Date): string {
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/** Everything the DownloadBar render needs — the hook's public surface. */
|
||||
export interface DownloadBarController {
|
||||
// form state
|
||||
url: string
|
||||
kind: MediaKind
|
||||
quality: string
|
||||
// trim / schedule
|
||||
showTrim: boolean
|
||||
setShowTrim: Dispatch<SetStateAction<boolean>>
|
||||
trim: string
|
||||
showSchedule: boolean
|
||||
setShowSchedule: Dispatch<SetStateAction<boolean>>
|
||||
scheduleAt: string
|
||||
setScheduleAt: Dispatch<SetStateAction<string>>
|
||||
// drag / dup
|
||||
dragActive: boolean
|
||||
setDragActive: Dispatch<SetStateAction<boolean>>
|
||||
dup: string | null
|
||||
setDup: Dispatch<SetStateAction<string | null>>
|
||||
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
|
||||
dupInHistory: boolean
|
||||
// advanced / preview
|
||||
showAdvanced: boolean
|
||||
setShowAdvanced: Dispatch<SetStateAction<boolean>>
|
||||
downloadOptions: DownloadOptions
|
||||
incognito: boolean
|
||||
setIncognito: Dispatch<SetStateAction<boolean>>
|
||||
commandPreview: CommandPreviewResult | null
|
||||
previewLoading: boolean
|
||||
// probe (single video)
|
||||
info: MediaInfo | null
|
||||
thumbFailed: string | null
|
||||
setThumbFailed: Dispatch<SetStateAction<string | null>>
|
||||
probing: boolean
|
||||
probeError: string | null
|
||||
usingFormats: boolean
|
||||
selectedFormat: FormatOption | undefined
|
||||
// playlist
|
||||
playlist: PlaylistInfo | null
|
||||
selected: Set<number>
|
||||
allSelected: boolean
|
||||
// per-item format picker (video rows only)
|
||||
formatExpanded: Set<number>
|
||||
itemFormatOptions: Record<number, FormatOption[]>
|
||||
itemFormat: (index: number) => ChosenFormat | undefined
|
||||
formatProbing: Set<number>
|
||||
formatProbeError: Record<number, string>
|
||||
// suggestion banner
|
||||
suggestion: string | null
|
||||
suggestionSource: SuggestionSource
|
||||
dismissSuggestion: () => void
|
||||
// channel/playlist nudge → Library (UX3)
|
||||
channelHint: boolean
|
||||
openInLibrary: () => void
|
||||
dismissChannelHint: () => void
|
||||
// handlers
|
||||
onDragEnter: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onUrlChange: (next: string) => void
|
||||
onKindChange: (next: MediaKind) => void
|
||||
onQualityChange: (v: string) => void
|
||||
onFormatIdChange: (v: string) => void
|
||||
onTrimChange: (v: string) => void
|
||||
onDownloadOptionsChange: (next: DownloadOptions) => void
|
||||
toggleCommandPreview: () => Promise<void>
|
||||
fetchFormats: () => Promise<void>
|
||||
paste: () => Promise<void>
|
||||
acceptSuggestion: () => void
|
||||
download: () => void
|
||||
downloadAnyway: () => void
|
||||
addPlaylist: () => void
|
||||
toggleEntry: (index: number, on: boolean) => void
|
||||
toggleAll: () => void
|
||||
effKind: (index: number) => MediaKind
|
||||
toggleItemKind: (index: number) => void
|
||||
setAllKinds: (k: MediaKind) => void
|
||||
toggleItemFormat: (index: number) => Promise<void>
|
||||
setItemFormat: (index: number, formatId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* All state and behaviour for the DownloadBar. Extracted from the component so
|
||||
* the ~17 interdependent state hooks and their handlers live in one place and
|
||||
* the component is render-only.
|
||||
*/
|
||||
export function useDownloadBar(): DownloadBarController {
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
const addMany = useDownloads((s) => s.addMany)
|
||||
const openLibraryWith = useNav((s) => s.openLibraryWith)
|
||||
const settingsLoaded = useSettings((s) => s.loaded)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [kind, setKind] = useState<MediaKind>('video')
|
||||
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
|
||||
|
||||
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||
const [showTrim, setShowTrim] = useState(false)
|
||||
const [trim, setTrim] = useState('')
|
||||
|
||||
// Optional schedule: a datetime-local value; a future time parks the download.
|
||||
const [showSchedule, setShowSchedule] = useState(false)
|
||||
const [scheduleAt, setScheduleAt] = useState('')
|
||||
|
||||
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
|
||||
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
|
||||
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
|
||||
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
|
||||
|
||||
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||
const [dup, setDup] = useState<string | null>(null)
|
||||
// Whether the duplicate matched a history entry (already downloaded) rather than
|
||||
// a live queue item (L144) — the banner wording differs.
|
||||
const [dupInHistory, setDupInHistory] = useState(false)
|
||||
const confirmDup = useRef(false)
|
||||
|
||||
// Per-download options (M5/M6/UX1): advanced features.
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [downloadOptions, setDownloadOptions] = useState<DownloadOptions>(DEFAULT_DOWNLOAD_OPTIONS)
|
||||
const [incognito, setIncognito] = useState(false)
|
||||
const [commandPreview, setCommandPreview] = useState<CommandPreviewResult | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
|
||||
// Probe state for the single-video format picker.
|
||||
const [info, setInfo] = useState<MediaInfo | null>(null)
|
||||
const [thumbFailed, setThumbFailed] = useState<string | null>(null)
|
||||
const [probing, setProbing] = useState(false)
|
||||
const [probeError, setProbeError] = useState<string | null>(null)
|
||||
const [formatId, setFormatId] = useState<string>('')
|
||||
|
||||
// Probe state for a playlist URL.
|
||||
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
|
||||
// bar's global kind. Lets a playlist mix video and audio downloads.
|
||||
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
||||
|
||||
// Per-entry exact-format selection (video rows only). A playlist probe is flat —
|
||||
// it carries no per-entry formats — so each row's formats are fetched lazily on
|
||||
// first expand (never all up front: a playlist can hold thousands of entries and
|
||||
// per-entry probes are rate-limited). `formatExpanded` = rows showing the picker;
|
||||
// `itemFormatOptions` caches a row's probed formats; `itemFormats` = the chosen
|
||||
// exact format per row (absent = use the kind/quality preset path).
|
||||
const [formatExpanded, setFormatExpanded] = useState<Set<number>>(new Set())
|
||||
const [itemFormatOptions, setItemFormatOptions] = useState<Record<number, FormatOption[]>>({})
|
||||
const [itemFormats, setItemFormats] = useState<Record<number, ChosenFormat>>({})
|
||||
const [formatProbing, setFormatProbing] = useState<Set<number>>(new Set())
|
||||
const [formatProbeError, setFormatProbeError] = useState<Record<number, string>>({})
|
||||
|
||||
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
|
||||
// dragleave fires every time the cursor crosses onto a child element, so toggling
|
||||
// on leave made the dashed outline blink. Count enters vs leaves and only clear
|
||||
// the highlight when the drag has actually left the whole card (depth back to 0).
|
||||
const dragDepth = useRef(0)
|
||||
function onDragEnter(e: React.DragEvent): void {
|
||||
e.preventDefault()
|
||||
dragDepth.current += 1
|
||||
if (!dragActive) setDragActive(true)
|
||||
}
|
||||
function onDragOver(e: React.DragEvent): void {
|
||||
// Required so the element is a valid drop target; the highlight is owned by
|
||||
// onDragEnter/onDragLeave.
|
||||
e.preventDefault()
|
||||
}
|
||||
function onDragLeave(): void {
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
||||
if (dragDepth.current === 0) setDragActive(false)
|
||||
}
|
||||
function onDrop(e: React.DragEvent): void {
|
||||
e.preventDefault()
|
||||
dragDepth.current = 0
|
||||
setDragActive(false)
|
||||
const dt = e.dataTransfer
|
||||
const fromText = firstUrlInText(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
|
||||
if (fromText) {
|
||||
onUrlChange(fromText)
|
||||
return
|
||||
}
|
||||
// A dropped Windows .url Internet Shortcut -- read its URL= line.
|
||||
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
|
||||
if (file) {
|
||||
file
|
||||
.text()
|
||||
.then((content) => {
|
||||
const u = parseUrlFile(content)
|
||||
if (u) onUrlChange(u)
|
||||
})
|
||||
.catch(logError('dropped .url file read'))
|
||||
}
|
||||
}
|
||||
|
||||
// When settings load or change, update kind/quality -- but only while the bar
|
||||
// is idle (URL empty) so an in-progress setup isn't unexpectedly reset.
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded || url.trim()) return
|
||||
setKind(defaultKind)
|
||||
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
|
||||
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
|
||||
|
||||
// Clipboard auto-detect and links handed to AeroFetch from outside (the
|
||||
// aerofetch:// protocol or a "Send to" .url file) share one suggestion banner,
|
||||
// driven by the same hook the library's add-source field uses. An external link
|
||||
// always takes priority over a clipboard guess -- it's a direct request.
|
||||
const {
|
||||
suggestion,
|
||||
source: suggestionSource,
|
||||
accept: acceptLink,
|
||||
dismiss: dismissSuggestion,
|
||||
offer: offerLink
|
||||
} = useClipboardLink(url)
|
||||
useEffect(
|
||||
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
|
||||
[offerLink]
|
||||
)
|
||||
|
||||
function acceptSuggestion(): void {
|
||||
const link = acceptLink()
|
||||
if (link) onUrlChange(link)
|
||||
}
|
||||
|
||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||
const selectedFormat: FormatOption | undefined = usingFormats
|
||||
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
|
||||
: undefined
|
||||
|
||||
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
|
||||
// but only while the bar is still idle for it (nothing probed/resolved yet) and
|
||||
// the user hasn't waved it away (UX3).
|
||||
const channelHint =
|
||||
looksLikeChannelOrPlaylist(url) &&
|
||||
!channelHintDismissed &&
|
||||
info === null &&
|
||||
playlist === null &&
|
||||
probeError === null
|
||||
|
||||
// Per-entry format state is keyed by playlist position, so it must be dropped
|
||||
// any time the playlist itself is dropped or re-fetched -- otherwise a stale
|
||||
// exact-format pick from a prior probe can silently reattach to a different
|
||||
// video that lands at the same index in the new result.
|
||||
function resetPlaylistItemState(): void {
|
||||
setSelected(new Set())
|
||||
setItemKinds({})
|
||||
setFormatExpanded(new Set())
|
||||
setItemFormatOptions({})
|
||||
setItemFormats({})
|
||||
setFormatProbing(new Set())
|
||||
setFormatProbeError({})
|
||||
}
|
||||
|
||||
function clearProbe(): void {
|
||||
setInfo(null)
|
||||
setProbeError(null)
|
||||
setFormatId('')
|
||||
setPlaylist(null)
|
||||
resetPlaylistItemState()
|
||||
}
|
||||
|
||||
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
|
||||
// probe and drop the one-shot overrides (trim / schedule / advanced / incognito /
|
||||
// per-download options) so the next download starts from the global defaults.
|
||||
// Shared by download() and addPlaylist() so a playlist add resets the same state
|
||||
// a single download does (L132 — addPlaylist previously left trim/schedule open).
|
||||
function resetForm(): void {
|
||||
setUrl('')
|
||||
setTrim('')
|
||||
setShowTrim(false)
|
||||
setScheduleAt('')
|
||||
setShowSchedule(false)
|
||||
setShowAdvanced(false)
|
||||
setCommandPreview(null)
|
||||
setIncognito(false)
|
||||
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
|
||||
clearProbe()
|
||||
}
|
||||
|
||||
// Fetch (or toggle off) the exact yt-dlp command line for the current form
|
||||
// state. The options mirror what download() sends so the preview matches the
|
||||
// command that will actually run -- including a probe-selected format and any
|
||||
// trim spec (M5).
|
||||
async function toggleCommandPreview(): Promise<void> {
|
||||
if (commandPreview) {
|
||||
setCommandPreview(null)
|
||||
return
|
||||
}
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const result = await window.api.previewCommand({
|
||||
id: 'preview',
|
||||
url: trimmed,
|
||||
kind: usingFormats ? 'video' : kind,
|
||||
quality: usingFormats && selectedFormat ? selectedFormat.label : quality,
|
||||
formatId: usingFormats ? selectedFormat?.id : undefined,
|
||||
formatHasAudio: usingFormats ? selectedFormat?.hasAudio : undefined,
|
||||
options: downloadOptions,
|
||||
trim: trim.trim() || undefined
|
||||
})
|
||||
setCommandPreview(result)
|
||||
} catch (e) {
|
||||
setCommandPreview({ ok: false, error: (e as Error).message })
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onUrlChange(next: string): void {
|
||||
setUrl(next)
|
||||
// Any probed info -- or a duplicate warning -- is stale once the URL changes.
|
||||
if (info || probeError || playlist) clearProbe()
|
||||
if (dup) setDup(null)
|
||||
setCommandPreview(null)
|
||||
confirmDup.current = false
|
||||
// A new URL gets a fresh chance to nudge toward the Library.
|
||||
setChannelHintDismissed(false)
|
||||
}
|
||||
|
||||
function onKindChange(next: MediaKind): void {
|
||||
setKind(next)
|
||||
setQuality(QUALITY_OPTIONS[next][0])
|
||||
// The command changes with kind/quality, so any shown preview is now stale.
|
||||
setCommandPreview(null)
|
||||
}
|
||||
|
||||
// Any change to quality / format / trim / options invalidates a shown preview.
|
||||
function onQualityChange(v: string): void {
|
||||
setQuality(v)
|
||||
setCommandPreview(null)
|
||||
}
|
||||
function onFormatIdChange(v: string): void {
|
||||
setFormatId(v)
|
||||
setCommandPreview(null)
|
||||
}
|
||||
function onTrimChange(v: string): void {
|
||||
setTrim(v)
|
||||
setCommandPreview(null)
|
||||
}
|
||||
function onDownloadOptionsChange(next: DownloadOptions): void {
|
||||
setDownloadOptions(next)
|
||||
setCommandPreview(null)
|
||||
}
|
||||
|
||||
async function fetchFormats(): Promise<void> {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed || probing) return
|
||||
setProbing(true)
|
||||
setProbeError(null)
|
||||
setInfo(null)
|
||||
setPlaylist(null)
|
||||
resetPlaylistItemState()
|
||||
// Probing may switch the URL to the format-picker path, changing the command.
|
||||
setCommandPreview(null)
|
||||
try {
|
||||
const res = await window.api.probe(trimmed)
|
||||
if (res.ok && res.kind === 'playlist' && res.playlist) {
|
||||
setPlaylist(res.playlist)
|
||||
// Pre-select every entry; the user can trim the list.
|
||||
setSelected(new Set(res.playlist.entries.map((e) => e.index)))
|
||||
} else if (res.ok && res.info) {
|
||||
setInfo(res.info)
|
||||
setFormatId(res.info.formats[0]?.id ?? '')
|
||||
} else {
|
||||
setProbeError(res.error ?? 'Could not fetch video info.')
|
||||
}
|
||||
} catch (e) {
|
||||
setProbeError(String(e))
|
||||
} finally {
|
||||
setProbing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function paste(): Promise<void> {
|
||||
try {
|
||||
const text = (await window.api?.readClipboard?.()) ?? ''
|
||||
if (text) onUrlChange(text.trim())
|
||||
} catch {
|
||||
/* ignore in preview */
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
|
||||
// add field with it, and clear the bar (the URL now lives over there) (UX3).
|
||||
function openInLibrary(): void {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
openLibraryWith(trimmed)
|
||||
setUrl('')
|
||||
// Clear the URL-specific command preview too, so no stale command lingers over
|
||||
// the now-empty bar (the same invariant onUrlChange keeps).
|
||||
setCommandPreview(null)
|
||||
clearProbe()
|
||||
}
|
||||
function dismissChannelHint(): void {
|
||||
setChannelHintDismissed(true)
|
||||
}
|
||||
|
||||
// Selection helpers for the playlist panel.
|
||||
const allSelected = playlist !== null && selected.size === playlist.entries.length
|
||||
function toggleEntry(index: number, on: boolean): void {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(index)
|
||||
else next.delete(index)
|
||||
return next
|
||||
})
|
||||
}
|
||||
function toggleAll(): void {
|
||||
if (!playlist) return
|
||||
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
||||
}
|
||||
|
||||
// Per-entry kind: the override if set, else the bar's global kind.
|
||||
function effKind(index: number): MediaKind {
|
||||
return itemKinds[index] ?? kind
|
||||
}
|
||||
function toggleItemKind(index: number): void {
|
||||
const next = effKind(index) === 'audio' ? 'video' : 'audio'
|
||||
setItemKinds((m) => ({ ...m, [index]: next }))
|
||||
// An exact format is a video-only choice; drop it (and the picker) when the
|
||||
// row switches to audio so the audio download uses the quality preset.
|
||||
if (next === 'audio') dropItemFormat(index)
|
||||
}
|
||||
function setAllKinds(k: MediaKind): void {
|
||||
if (!playlist) return
|
||||
const all: Record<number, MediaKind> = {}
|
||||
for (const e of playlist.entries) all[e.index] = k
|
||||
setItemKinds(all)
|
||||
// Same reason as toggleItemKind: an "All audio" flip clears every per-item format.
|
||||
if (k === 'audio') {
|
||||
setItemFormats({})
|
||||
setFormatExpanded(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
// Per-entry exact-format helpers.
|
||||
function itemFormat(index: number): ChosenFormat | undefined {
|
||||
return itemFormats[index]
|
||||
}
|
||||
function dropItemFormat(index: number): void {
|
||||
setItemFormats((m) => {
|
||||
const { [index]: _drop, ...rest } = m
|
||||
return rest
|
||||
})
|
||||
setFormatExpanded((s) => {
|
||||
const next = new Set(s)
|
||||
next.delete(index)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Expand/collapse a row's format picker. On first expand, lazily probe just that
|
||||
// entry's URL — the existing single-video probe returns its full format list — and
|
||||
// cache it so re-expanding is instant.
|
||||
async function toggleItemFormat(index: number): Promise<void> {
|
||||
if (formatExpanded.has(index)) {
|
||||
setFormatExpanded((s) => {
|
||||
const next = new Set(s)
|
||||
next.delete(index)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
setFormatExpanded((s) => new Set(s).add(index))
|
||||
if (itemFormatOptions[index] || formatProbing.has(index)) return // already have (or fetching) formats
|
||||
const entry = playlist?.entries.find((e) => e.index === index)
|
||||
if (!entry) return
|
||||
setFormatProbing((s) => new Set(s).add(index))
|
||||
setFormatProbeError((m) => {
|
||||
const { [index]: _drop, ...rest } = m
|
||||
return rest
|
||||
})
|
||||
try {
|
||||
const res = await window.api.probe(entry.url)
|
||||
if (res.ok && res.kind === 'video' && res.info) {
|
||||
setItemFormatOptions((m) => ({ ...m, [index]: res.info!.formats }))
|
||||
} else {
|
||||
setFormatProbeError((m) => ({ ...m, [index]: res.error ?? 'Could not fetch formats.' }))
|
||||
}
|
||||
} catch (e) {
|
||||
setFormatProbeError((m) => ({ ...m, [index]: String(e) }))
|
||||
} finally {
|
||||
setFormatProbing((s) => {
|
||||
const next = new Set(s)
|
||||
next.delete(index)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pick an exact format for a row (or clear back to the preset for "Best available").
|
||||
function setItemFormat(index: number, formatId: string): void {
|
||||
const opt = itemFormatOptions[index]?.find((o) => o.id === formatId)
|
||||
if (!opt || opt.id === BEST_FORMAT_ID) {
|
||||
// "Best available" is the auto/preset path — carry no exact format.
|
||||
setItemFormats((m) => {
|
||||
const { [index]: _drop, ...rest } = m
|
||||
return rest
|
||||
})
|
||||
return
|
||||
}
|
||||
setItemFormats((m) => ({
|
||||
...m,
|
||||
[index]: { id: opt.id, hasAudio: opt.hasAudio, label: opt.label }
|
||||
}))
|
||||
// Choosing a video format implies this row is a video download.
|
||||
if (effKind(index) === 'audio') setItemKinds((m) => ({ ...m, [index]: 'video' }))
|
||||
}
|
||||
|
||||
function download(): void {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
// Duplicate guard: if this URL is already in the queue (and the user hasn't
|
||||
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
|
||||
// Canceled/failed items don't count -- re-adding those is a legitimate retry.
|
||||
if (!confirmDup.current) {
|
||||
const existing = useDownloads
|
||||
.getState()
|
||||
.items.find(
|
||||
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
||||
)
|
||||
if (existing) {
|
||||
// Use the URL as fallback if the title is still the placeholder generated
|
||||
// by titleFromUrl() before metadata loads (L76).
|
||||
const isPlaceholder =
|
||||
/video \(\w+\)$/.test(existing.title) ||
|
||||
existing.title.endsWith(' download') ||
|
||||
existing.title === 'New download'
|
||||
setDup(isPlaceholder ? existing.url : existing.title)
|
||||
setDupInHistory(false)
|
||||
return
|
||||
}
|
||||
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
|
||||
// re-download is a deliberate choice, not an accidental duplicate.
|
||||
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
|
||||
if (downloaded) {
|
||||
setDup(downloaded.title || downloaded.url)
|
||||
setDupInHistory(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
confirmDup.current = false
|
||||
setDup(null)
|
||||
|
||||
const meta = info
|
||||
? {
|
||||
title: info.title,
|
||||
channel: info.channel,
|
||||
durationLabel: info.durationLabel,
|
||||
thumbnail: info.thumbnail
|
||||
}
|
||||
: {}
|
||||
|
||||
const trimSpec = trim.trim() || undefined
|
||||
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
|
||||
|
||||
if (usingFormats && selectedFormat) {
|
||||
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
||||
...meta,
|
||||
trim: trimSpec,
|
||||
scheduledFor,
|
||||
options: downloadOptions,
|
||||
incognito,
|
||||
format: {
|
||||
id: selectedFormat.id,
|
||||
hasAudio: selectedFormat.hasAudio,
|
||||
label: selectedFormat.label
|
||||
}
|
||||
})
|
||||
} else {
|
||||
addFromUrl(trimmed, kind, quality, {
|
||||
...meta,
|
||||
trim: trimSpec,
|
||||
scheduledFor,
|
||||
options: downloadOptions,
|
||||
incognito
|
||||
})
|
||||
}
|
||||
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function downloadAnyway(): void {
|
||||
confirmDup.current = true
|
||||
download()
|
||||
}
|
||||
|
||||
function addPlaylist(): void {
|
||||
if (!playlist) return
|
||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||
// Each entry uses its own kind override and, for a video row the user gave an
|
||||
// exact probed format, that format; otherwise quality falls back to the bar's
|
||||
// current preset (when the kind matches) or that kind's default (see the pure
|
||||
// buildPlaylistAddEntries for the mapping).
|
||||
addMany(
|
||||
buildPlaylistAddEntries(chosen, { barKind: kind, barQuality: quality, effKind, itemFormats })
|
||||
)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return {
|
||||
// form state
|
||||
url,
|
||||
kind,
|
||||
quality,
|
||||
// trim / schedule
|
||||
showTrim,
|
||||
setShowTrim,
|
||||
trim,
|
||||
showSchedule,
|
||||
setShowSchedule,
|
||||
scheduleAt,
|
||||
setScheduleAt,
|
||||
// drag / dup
|
||||
dupInHistory,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
dup,
|
||||
setDup,
|
||||
// advanced / preview
|
||||
showAdvanced,
|
||||
setShowAdvanced,
|
||||
downloadOptions,
|
||||
incognito,
|
||||
setIncognito,
|
||||
commandPreview,
|
||||
previewLoading,
|
||||
// probe (single video)
|
||||
info,
|
||||
thumbFailed,
|
||||
setThumbFailed,
|
||||
probing,
|
||||
probeError,
|
||||
usingFormats,
|
||||
selectedFormat,
|
||||
// playlist
|
||||
playlist,
|
||||
selected,
|
||||
allSelected,
|
||||
// per-item format picker
|
||||
formatExpanded,
|
||||
itemFormatOptions,
|
||||
itemFormat,
|
||||
formatProbing,
|
||||
formatProbeError,
|
||||
// suggestion banner
|
||||
suggestion,
|
||||
suggestionSource,
|
||||
dismissSuggestion,
|
||||
// channel/playlist nudge → Library (UX3)
|
||||
channelHint,
|
||||
openInLibrary,
|
||||
dismissChannelHint,
|
||||
// handlers
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onUrlChange,
|
||||
onKindChange,
|
||||
onQualityChange,
|
||||
onFormatIdChange,
|
||||
onTrimChange,
|
||||
onDownloadOptionsChange,
|
||||
toggleCommandPreview,
|
||||
fetchFormats,
|
||||
paste,
|
||||
acceptSuggestion,
|
||||
download,
|
||||
downloadAnyway,
|
||||
addPlaylist,
|
||||
toggleEntry,
|
||||
toggleAll,
|
||||
effKind,
|
||||
toggleItemKind,
|
||||
setAllKinds,
|
||||
toggleItemFormat,
|
||||
setItemFormat
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { RADIUS } from './tokens'
|
||||
import { useTextStyles } from './text'
|
||||
|
||||
/**
|
||||
* One inline notification bar (UI19). The DownloadBar's copied-link suggestion,
|
||||
* its channel/playlist nudge, its duplicate warning, and the Library's copied-link
|
||||
* suggestion had each hand-rolled the same tinted row (leading icon + flex-grow
|
||||
* text + trailing actions). This is that row, once: a leading `icon`, `children`
|
||||
* content that truncates by default (`wrap` to let it flow onto multiple lines),
|
||||
* and a trailing `actions` slot.
|
||||
*
|
||||
* `tone` picks the semantic tint — `brand` for suggestions/nudges, `warning` for
|
||||
* the duplicate caution — matching the former per-component colors exactly.
|
||||
*/
|
||||
export type BannerTone = 'brand' | 'warning'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
...shorthands.borderRadius(RADIUS.surface)
|
||||
},
|
||||
brand: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
color: tokens.colorStatusWarningForeground1
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
}
|
||||
})
|
||||
|
||||
export function Banner({
|
||||
icon,
|
||||
tone = 'brand',
|
||||
wrap = false,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
tone?: BannerTone
|
||||
/** Let the content wrap onto multiple lines instead of truncating (the channel nudge). */
|
||||
wrap?: boolean
|
||||
children: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const text = useTextStyles()
|
||||
return (
|
||||
<div className={mergeClasses(styles.root, styles[tone])}>
|
||||
{icon}
|
||||
<div className={mergeClasses(styles.content, !wrap && text.truncate)}>{children}</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Body1, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { SPACE } from './tokens'
|
||||
|
||||
/**
|
||||
* One shared empty-state block (L116/L117). The screens had drifted into three
|
||||
* shapes — Downloads (icon + line), History (colored badge + line + sub-hint),
|
||||
* Library (icon + line, no sub-hint) — and the Terminal / Diagnostics "empty"
|
||||
* text wasn't a centered block at all (inline text). This centers them all on one
|
||||
* structure: an optional focal `icon` (a hero glyph or a colored badge), a `Body1`
|
||||
* message at normal foreground, and an optional muted `hint` line.
|
||||
*
|
||||
* `compact` swaps the tall screen-level padding (56px) for a smaller inset (32px),
|
||||
* for placeholders that sit inside an already-populated screen (the Terminal log,
|
||||
* the Diagnostics list) rather than filling an empty one.
|
||||
*/
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: SPACE.tight,
|
||||
padding: '56px 16px',
|
||||
// The container is muted, so a hero glyph and the hint line read as secondary;
|
||||
// the message overrides back to the normal foreground so it stays legible.
|
||||
color: tokens.colorNeutralForeground3,
|
||||
textAlign: 'center'
|
||||
},
|
||||
compact: {
|
||||
padding: '32px 16px'
|
||||
},
|
||||
message: {
|
||||
color: tokens.colorNeutralForeground1
|
||||
}
|
||||
})
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
message,
|
||||
hint,
|
||||
compact = false,
|
||||
className
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
message: React.ReactNode
|
||||
hint?: React.ReactNode
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<div className={mergeClasses(styles.root, compact && styles.compact, className)}>
|
||||
{icon}
|
||||
<Body1 className={styles.message}>{message}</Body1>
|
||||
{hint && <Caption1>{hint}</Caption1>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './focusRing'
|
||||
import { ICON } from './tokens'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
btn: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
},
|
||||
':disabled': {
|
||||
cursor: 'not-allowed',
|
||||
color: tokens.colorNeutralForegroundDisabled,
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
},
|
||||
md: { width: '32px', height: '32px', fontSize: `${ICON.control}px` },
|
||||
sm: { width: '28px', height: '28px', fontSize: `${ICON.inline}px` }
|
||||
})
|
||||
|
||||
type IconButtonProps = {
|
||||
icon: React.JSX.Element
|
||||
size?: 'sm' | 'md'
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
/**
|
||||
* The recurring icon-only ghost button (UI15) — one implementation for the
|
||||
* sidebar's collapse/theme toggles and the playlist row's video/audio switch,
|
||||
* which were separate hand-rolled buttons with the same look. Carries the shared
|
||||
* focus ring (UI29). Extra props (onClick, aria-label, aria-pressed, disabled, …)
|
||||
* pass straight through to the underlying `<button>`.
|
||||
*/
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{ icon, size = 'md', className, type = 'button', ...rest },
|
||||
ref
|
||||
) {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={mergeClasses(styles.btn, styles[size], focus.focusRing, className)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Caption1 } from '@fluentui/react-components'
|
||||
import { LinkRegular, DismissRegular } from '@fluentui/react-icons'
|
||||
import { Banner } from './Banner'
|
||||
|
||||
/**
|
||||
* The "use this copied/received link?" banner (UI19), shared by the DownloadBar
|
||||
* and the Library add-source field, which had identical markup + CSS. Renders the
|
||||
* link (with an optional lead-in like "Link received: ") and Use / Dismiss actions
|
||||
* on the shared {@link Banner}.
|
||||
*/
|
||||
export function LinkSuggestion({
|
||||
prefix,
|
||||
link,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
dismissLabel
|
||||
}: {
|
||||
/** Short lead-in before the link, e.g. "Use copied link? " or "Link received: ". */
|
||||
prefix: string
|
||||
link: string
|
||||
onAccept: () => void
|
||||
onDismiss: () => void
|
||||
/** Accessible name for the dismiss button — say what's being dismissed (L153). */
|
||||
dismissLabel: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Banner
|
||||
icon={<LinkRegular />}
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" appearance="primary" onClick={onAccept}>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={onDismiss}
|
||||
aria-label={dismissLabel}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Caption1>
|
||||
{prefix}
|
||||
{link}
|
||||
</Caption1>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
|
||||
import { SPACE } from './tokens'
|
||||
|
||||
/**
|
||||
* One shared content max-width for every screen (UI1), plus the screen scroll
|
||||
* contract (L161).
|
||||
*
|
||||
* Settings used to be a 640px column while the list screens (Downloads, Library,
|
||||
* History, Terminal) were full-width, so on a wide window Settings read as an odd
|
||||
* narrow strip beside edge-to-edge siblings. This caps them all at the same
|
||||
* reading width and centers, so they line up. On typical window sizes the content
|
||||
* is already under the cap, so the list screens are visually unchanged — only the
|
||||
* wide-window upper bound is now shared.
|
||||
*
|
||||
* Scroll contract (L161): App's `<main>` is a non-scrolling flex column — each
|
||||
* screen owns its scrolling through one of two shells, so a page scroll and an
|
||||
* internal list scroll can never stack:
|
||||
*
|
||||
* - `page` — document screens (Library, Settings, empty History): the shell
|
||||
* scrolls; content is natural height.
|
||||
* - `fill` — internally-scrolling screens (Downloads, Terminal, History): the
|
||||
* shell fills the viewport and hides overflow; the screen's own list/log
|
||||
* region (VirtualList, the terminal <pre>) is the only scroller.
|
||||
*
|
||||
* Usage: shell div outside, `mergeClasses(styles.root, screen.width)` inside.
|
||||
* Inside a `fill` shell the inner root uses `flexGrow: 1; minHeight: 0` (not
|
||||
* `height: 100%`, which is what coupled screens to `<main>`'s padding before).
|
||||
*/
|
||||
export const useScreenStyles = makeStyles({
|
||||
width: {
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto'
|
||||
},
|
||||
page: {
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
padding: SPACE.page
|
||||
},
|
||||
fill: {
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Normally content fits and only the screen's internal list/log scrolls; on a
|
||||
// very short window the fixed-height rows above the list (header, download
|
||||
// bar) plus the list's min-height floor overflow, and the shell scrolls as
|
||||
// the fallback instead of the floor collapsing to 0px.
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
padding: SPACE.page
|
||||
}
|
||||
})
|
||||
|
||||
const useStyles = makeStyles({
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
titleBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
minWidth: 0,
|
||||
flexGrow: 1
|
||||
},
|
||||
description: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The consistent screen header block (UI9/UI10): one page-title style on every
|
||||
* screen, with an optional one-line description and an optional right-aligned
|
||||
* action slot. Previously each screen rolled its own (Subtitle2 / Title2 / none),
|
||||
* and only Library had a description.
|
||||
*/
|
||||
export function ScreenHeader({
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}: {
|
||||
title: string
|
||||
description?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleBlock}>
|
||||
<Subtitle2 as="h1">{title}</Subtitle2>
|
||||
{description && <Caption1 className={styles.description}>{description}</Caption1>}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user