diff --git a/ROADMAP.md b/ROADMAP.md index ce2fcfe..021a5e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -391,28 +391,30 @@ All in the main process. typecheck + test + `npm run build` clean. 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 +## Phase P — Reliability, longevity & trust ✅ (code shipped; cert + manual passes still required) -The least glamorous, highest-leverage track: make sure what's *already shipped* actually works, -keep YouTube downloads working as the platform tightens, and remove first-run friction. +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. -- [ ] **PO Token generation.** YTDLnis mints YouTube tokens in a WebView to dodge throttling - and "Sign in to confirm you're not a bot." This is becoming **required** for YouTube to - keep working at all. → reuse the existing persisted Electron `BrowserWindow` - (`persist:aerofetch-login` in `src/main/cookies.ts`) to host a token-minting script, or - support a PO-token provider, passed via `--extractor-args youtube:po_token=…`. A - longevity hedge more than a feature. -- [ ] **Verify the shipped-but-untested OS wiring** (flagged across both parity tracks as - "reasoning + typecheck only"). A single real NSIS-install smoke-test pass over: the - cookies sign-in window export · the `aerofetch://` protocol + Explorer "Send to" · the - scheduled `--sync` + RSS fast-check (Phase J) · aria2c · proxy. Converts a lot of - "probably works" into "verified." -- [ ] **Code signing.** The unsigned `.exe` triggers SmartScreen "Run anyway" — the single - biggest first-run friction for anyone you share it with. electron-builder `win` signing - (OV/EV cert); EV builds SmartScreen reputation fastest. -- [ ] **i18n / language setting.** Carried over from Phase E (deferred). YTDLnis and Seal both - ship many languages. Large but optional — needs an i18n library + string extraction, with - translations arriving incrementally. +- [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. --- diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 0000000..dcdfdf2 --- /dev/null +++ b/docs/SIGNING.md @@ -0,0 +1,77 @@ +# 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 = "" +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 .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 .exe" | Format-List +# Status should be 'Valid'; SignerCertificate should be your cert. +signtool verify /pa /v ".\dist\AeroFetch Setup .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). diff --git a/docs/SMOKE-TEST.md b/docs/SMOKE-TEST.md new file mode 100644 index 0000000..d2b9e58 --- /dev/null +++ b/docs/SMOKE-TEST.md @@ -0,0 +1,61 @@ +# 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=` → + 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 ` → 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. diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index 23fd4e8..7b66cf3 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -100,6 +100,10 @@ export interface AccessOptions { restrictFilenames: boolean /** absolute path to a --download-archive file; omit to disable archive tracking */ downloadArchivePath?: string + /** YouTube extraction client override (e.g. 'web_safari'); empty/omitted = default */ + youtubePlayerClient?: string + /** manually-supplied YouTube Proof-of-Origin token; empty/omitted = none */ + youtubePoToken?: string } // Tuned for typical CDN-served video: 16 connections split across 16 segments, @@ -278,6 +282,12 @@ function accessArgs(a: AccessOptions): string[] { else if (a.cookiesFile) args.push('--cookies', a.cookiesFile) if (a.restrictFilenames) args.push('--restrict-filenames') if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath) + // YouTube reliability: combine the player-client + PO-token overrides into one + // `youtube:` extractor-args group (semicolon-separated, as yt-dlp expects). + const yt: string[] = [] + if (a.youtubePlayerClient?.trim()) yt.push(`player_client=${a.youtubePlayerClient.trim()}`) + if (a.youtubePoToken?.trim()) yt.push(`po_token=${a.youtubePoToken.trim()}`) + if (yt.length > 0) args.push('--extractor-args', `youtube:${yt.join(';')}`) return args } diff --git a/src/main/download.ts b/src/main/download.ts index 23ac459..48f424b 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -214,7 +214,9 @@ export function buildCommand(opts: StartDownloadOptions): string[] { cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined, cookiesFile, restrictFilenames: settings.restrictFilenames, - downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined + downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined, + youtubePlayerClient: settings.youtubePlayerClient, + youtubePoToken: settings.youtubePoToken } const extraArgs = resolveExtraArgs(opts, settings) return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs) diff --git a/src/main/settings.ts b/src/main/settings.ts index 48e3a18..96c2558 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -36,6 +36,8 @@ const DEFAULTS: Settings = { 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 @@ -256,6 +258,8 @@ export function setSettings(partial: Partial): Settings { // 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 } diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index b819308..2d8aab7 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -226,6 +226,8 @@ export function SettingsView(): React.JSX.Element { const proxy = useSettings((s) => s.proxy) const rateLimit = useSettings((s) => s.rateLimit) const useAria2c = useSettings((s) => s.useAria2c) + const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient) + const youtubePoToken = useSettings((s) => s.youtubePoToken) const cookieSource = useSettings((s) => s.cookieSource) const cookiesBrowser = useSettings((s) => s.cookiesBrowser) const restrictFilenames = useSettings((s) => s.restrictFilenames) @@ -675,6 +677,28 @@ export function SettingsView(): React.JSX.Element { label={useAria2c ? 'On' : 'Off'} /> + + + update({ youtubePlayerClient: d.value })} + /> + + + + update({ youtubePoToken: d.value })} + /> + diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6724a4f..81c4d4e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -27,6 +27,8 @@ if (import.meta.env.DEV && !window.api) { useAria2c: false, cookieSource: 'none', cookiesBrowser: 'chrome', + youtubePlayerClient: '', + youtubePoToken: '', restrictFilenames: false, downloadArchive: false, autoUpdateYtdlp: true, diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index fbb33fd..d147810 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -21,6 +21,8 @@ const FALLBACK: Settings = { useAria2c: false, cookieSource: 'none', cookiesBrowser: 'chrome', + youtubePlayerClient: '', + youtubePoToken: '', restrictFilenames: false, downloadArchive: false, autoUpdateYtdlp: true, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 793d1f1..a6e6689 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -492,6 +492,17 @@ export interface Settings { cookieSource: CookieSource /** which browser to read cookies from when cookieSource is 'browser' */ cookiesBrowser: CookieBrowser + /** + * YouTube reliability (Phase P). When set, emitted as + * `--extractor-args "youtube:player_client=…;po_token=…"`: + * - playerClient: an alternate extraction client (e.g. 'web_safari', 'tv', + * 'mweb') — a common workaround for YouTube throttling/extraction breakage. + * - poToken: a manually-supplied Proof-of-Origin token for the bot check. + * Both empty = yt-dlp's defaults. (Automatic WebView token minting is deferred; + * this is the argv plumbing it would feed.) + */ + youtubePlayerClient: string + youtubePoToken: string /** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */ restrictFilenames: boolean /** record completed downloads in a yt-dlp --download-archive file to skip repeats */ diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index d0c6e6d..fb125d7 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -461,6 +461,28 @@ describe('sidecar files', () => { }) }) +describe('YouTube extractor-args', () => { + it('combines player_client and po_token into one youtube: group', () => { + const argv = build(opts(), dlo(), { + ...NO_ACCESS, + youtubePlayerClient: 'web_safari', + youtubePoToken: 'web.gvs+ABC' + }) + expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=web_safari;po_token=web.gvs+ABC')).toBe( + true + ) + }) + + it('emits only the fields that are set', () => { + const argv = build(opts(), dlo(), { ...NO_ACCESS, youtubePlayerClient: 'tv' }) + expect(hasSeq(argv, '--extractor-args', 'youtube:player_client=tv')).toBe(true) + }) + + it('emits nothing when both are empty', () => { + expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--extractor-args') + }) +}) + describe('format sorting (-S)', () => { it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => { const argv = build(opts(), dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' }))