From b7c463bfa98dbf51aeecc79af8ac3a261c9f366d Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 2 Sep 2026 11:53:36 -0700 Subject: [PATCH 1/5] feat(app): rework yt-dlp updater logic to fetch pre built binaries avalidate installation checksums --- .github/workflows/build.yml | 28 +- package-lock.json | 4 +- package.json | 4 +- scripts/fetch-ytdlp-bin.mjs | 43 ++ .../__fixtures__/ytdlp-release/SHA2-256SUMS | 19 + .../ytdlp-release/SHA2-256SUMS.sig | Bin 0 -> 566 bytes src/electron/main.mjs | 24 +- src/electron/preload.test.mjs | 4 +- src/electron/updater.mjs | 54 +- src/electron/ytdlp-public-key.asc | 29 + src/electron/ytdlpRelease.mjs | 537 ++++++++++++++++++ src/electron/ytdlpRelease.test.mjs | 389 +++++++++++++ src/ui/components/YtdlpUpdateDialog.test.tsx | 24 +- src/ui/components/YtdlpUpdateDialog.tsx | 24 +- src/ui/hooks/useYtdlpUpdater.test.tsx | 25 +- src/ui/hooks/useYtdlpUpdater.tsx | 34 +- src/ui/screens/OptionsScreen.tsx | 11 +- 17 files changed, 1182 insertions(+), 71 deletions(-) create mode 100644 scripts/fetch-ytdlp-bin.mjs create mode 100644 src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS create mode 100644 src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS.sig create mode 100644 src/electron/ytdlp-public-key.asc create mode 100644 src/electron/ytdlpRelease.mjs create mode 100644 src/electron/ytdlpRelease.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0af2b0e..8d35388 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -252,25 +252,19 @@ jobs: node-version: 24 cache: npm - # build-ytdlp-bin.mjs freezes yt-dlp into a standalone binary via a - # local venv + PyInstaller -- needs a real Python on PATH on every - # platform, not just whatever the runner image happens to ship. - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: pip - cache-dependency-path: src/python/requirements-build.txt - - run: npm ci - # Build only -- no network/GitHub calls happen during this step, it's - # purely local packaging. Deliberately NOT electron-builder's own - # `--publish` flow: its GitHub publisher has its own internal retry - # behavior around creating/uploading, and a retry there (e.g. after a - # transient network hiccup) has been the suspected cause of a single - # run ending up with duplicate release state before. Publishing is - # done explicitly below instead, as plain `gh` calls this workflow - # fully controls. + # Build only -- this step no longer publishes anything itself. It does + # make one read-only network call now (build:ytdlp:bin fetches and + # GPG-verifies yt-dlp's own signed release asset from GitHub, see + # scripts/fetch-ytdlp-bin.mjs), but that's an idempotent GET against an + # already-published release, unrelated to the actual risk this + # separation guards against: electron-builder's own `--publish` flow + # has internal retry behavior around *creating/uploading* a release, + # and a retry there (e.g. after a transient network hiccup) has been + # the suspected cause of a single run ending up with duplicate release + # state before. Publishing is done explicitly below instead, as plain + # `gh` calls this workflow fully controls. - name: Build run: npm run dist diff --git a/package-lock.json b/package-lock.json index 3056e8e..1a64e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sloth-archiver", - "version": "0.25.6", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sloth-archiver", - "version": "0.25.6", + "version": "0.26.0", "license": "GPL-3.0-or-later", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 1da7919..dc1e58c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sloth-archiver", "private": true, - "version": "0.25.6", + "version": "0.26.0", "description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.", "author": "Sloth ", "license": "GPL-3.0-or-later", @@ -70,7 +70,7 @@ "dist": "npm run build:all:deep && npm run electron-build", "build:renderer": "vite build", "build:copy:electron": "node scripts/copy-electron.mjs", - "build:ytdlp:bin": "node scripts/build-ytdlp-bin.mjs", + "build:ytdlp:bin": "node scripts/fetch-ytdlp-bin.mjs", "build:copy:ffmpeg": "node scripts/copy-ffmpeg.mjs", "build:electron": "npm run build:copy:electron && npm run build:ytdlp:bin && npm run build:copy:ffmpeg", "build:all": "npm run build:renderer && npm run build:electron", diff --git a/scripts/fetch-ytdlp-bin.mjs b/scripts/fetch-ytdlp-bin.mjs new file mode 100644 index 0000000..37b96e5 --- /dev/null +++ b/scripts/fetch-ytdlp-bin.mjs @@ -0,0 +1,43 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { resolveLatestRelease, mapPlatformToAssetName, detectMusl, fetchAndVerifyRelease } from '../src/electron/ytdlpRelease.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const workDir = path.join(rootDir, 'build', 'ytdlp-fetch'); +const finalDist = path.join(rootDir, 'dist', 'ytdlp-bin'); + +// Pinned so every SlothArchiver release bundles a known, reproducible +// yt-dlp version rather than "whatever GitHub's latest release happened to +// be at build time" -- the direct replacement for +// requirements-build.txt's own version pin. Bump this by hand in the same +// commit as any other dependency-version bump. +const PINNED_YTDLP_TAG = '2026.08.19'; + +fs.rmSync(workDir, { recursive: true, force: true }); +fs.rmSync(finalDist, { recursive: true, force: true }); + +const release = await resolveLatestRelease({ pin: PINNED_YTDLP_TAG }); +const assetName = mapPlatformToAssetName({ isMusl: detectMusl() }); + +console.log(`Fetching yt-dlp ${release.tag} (${assetName})...`); +const extractedDir = await fetchAndVerifyRelease({ + release, + assetName, + workDir, + onProgress: (stage) => console.log(`[fetch-ytdlp-bin] ${stage}`), + onLog: (msg) => console.log(msg), +}); + +// fetchAndVerifyRelease's ZIP extraction lands the launcher (already renamed +// to the canonical yt-dlp/yt-dlp.exe, see renameLauncherToCanonicalName) + +// _internal/ directly in extractedDir, with no extra top-level wrapper +// folder (confirmed against the real macOS asset) -- so this is just a +// rename into place, matching the final shape package.json's +// `dist/ytdlp-bin/` extraResources entry already expects. +fs.renameSync(extractedDir, finalDist); +fs.rmSync(workDir, { recursive: true, force: true }); + +const binName = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; +console.log(`Fetched yt-dlp ${release.tag} -> ${path.join(finalDist, binName)}`); diff --git a/src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS b/src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS new file mode 100644 index 0000000..6452566 --- /dev/null +++ b/src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS @@ -0,0 +1,19 @@ +1fa6733c37ea6fb51c99ad8fe785e7b7e5f3246c9b980230329d4fb72ed8d4d6 yt-dlp +66674953fe251b89f4d08c5f0e35e0728679bd67ab3d7d05c0562af101dd3e7a yt-dlp.exe +072aad4f2a7604e92155f61a275a4752dc64046c8f6d90df3710525d94cd37c1 yt-dlp.tar.gz +05b438997bafc3affdfda9d041353c9d73e04dc842207254b655b0887c4445b0 yt-dlp_arm64.exe +58162f9bfdc27458ea47bfcb311cf47028f17d8154a8bf7d689861d46399230a yt-dlp_linux +32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9 yt-dlp_linux.zip +b16e4dab368a816cd05d477d698a605a6ae87ccee1c8ffd38fa21d7254141fcc yt-dlp_linux_aarch64 +4e27ad43f3a34bacffd078694eb3edbb4e3b378e7da44edab2be02e98555516e yt-dlp_linux_aarch64.zip +bd51eb5fed7788008f4f30d281a182376a1297910b6c78e747403e757a546508 yt-dlp_linux_armv7l.zip +0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202 yt-dlp_macos +07e54b0865303c864006925913bce2604f8ee8cc6f18699bac9c309f9328a6d8 yt-dlp_macos.zip +f3dec9cfeaf304cec98290fe41c6ad465d4b747d302473559643e7af24929722 yt-dlp_musllinux +a8a9761628be455368db73ab54a2858da22342084f300f7e0d50bdcbef2acf82 yt-dlp_musllinux.zip +17b164c4d258be92bb1ad146cb7c336b783aedb380814aabbcb7d52937f77e57 yt-dlp_musllinux_aarch64 +46087f4965f48612d056c6e4b00a0898e8230ba90d709a0a751c7cc5735a1da3 yt-dlp_musllinux_aarch64.zip +30b4c14aafab6082becff7881e41b76df46dc43ea7633479410a91e29da492bf yt-dlp_win.zip +2ad49db7429a6204721f711a44199a8c9fa8d5df2183dd7fedc4ef6c9e2e50d3 yt-dlp_win_arm64.zip +dc7c11f014af67f23d9fd989868048abedbce01709ff642a29eb3f7578b802f7 yt-dlp_win_x86.zip +a8f91bd41452506bc81ebd2f369b186fea0ee7075413ba00cef9fd346a0a5d0c yt-dlp_x86.exe diff --git a/src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS.sig b/src/electron/__fixtures__/ytdlp-release/SHA2-256SUMS.sig new file mode 100644 index 0000000000000000000000000000000000000000..b2ff85665bbb8f13b3be739a1c973e09031cf3a2 GIT binary patch literal 566 zcmV-60?GY}0y6{v0SW*e79j+z47=upjcSK9WUo$F&t;Q4T6KX10&0dpxc~|Y5LeG- zlRH{F4_30IksYUl_>Ug$YO`(pKXEuOO(YQ@dFCmKm8RIzHF?LK`SxLC`3O??gsv^wcq z^RIw-jqI6p)lL;?1C(Wsh#MB0Sa*@NXuMkJeTFRvdz0SH|D_#>(^QQl4242OQdK?%L{mLS;FH!F|T&9&@Ab|G^ZTgEvA{_ut(t# z?0h-gWO!tqmgRAK>;Xt(X2n5daM)-|Pf-;s~%8{<0*;h}5!PwB_gG zLHilD&K*wQv>q-4d}^4qJIk zD4Ru6AC~J{WI$zOJpB+iXC+tS{G5a { - const [latest, current] = await Promise.all([ - getLatestYtdlpVersionFromPyPI(), + // GitHub Releases -- the same source performYtdlpUpdate actually fetches + // and verifies the binary from below -- rather than PyPI, which was only + // ever a proxy for "what version is latest" and could in principle + // disagree with the real update source. + const [release, current] = await Promise.all([ + resolveLatestRelease(), getCurrentYtdlpVersion(ytdlpPath), ]); - return { current, latest, updateAvailable: isNewerVersion(latest, current) }; + return { current, latest: release.tag, updateAvailable: isNewerVersion(release.tag, current) }; }); ipcMain.handle('ytdlp:startUpdate', async () => { - const send = (stage) => BrowserWindow.getAllWindows()[0]?.webContents.send('ytdlpUpdateProgress', { stage }); + // The second arg rides along on the same 'ytdlpUpdateProgress' broadcast + // channel as `stage` -- unlike a thrown Error's own properties (e.g. + // err.code below), which don't survive the trip from ipcMain.handle's + // throw to ipcRenderer.invoke's rejection, this is a plain + // structured-cloned object and arrives at the renderer intact. + const send = (stage, extra) => BrowserWindow.getAllWindows()[0]?.webContents.send('ytdlpUpdateProgress', { stage, ...extra }); try { const result = await performYtdlpUpdate({ userDataDir: app.getPath('userData'), - pythonSrcDir, liveYtdlpBinDir: userDataYtdlpBinDir, ytdlpBinaryName, isDownloadActive: () => activeDownloadCount > 0, @@ -1839,7 +1847,7 @@ ipcMain.handle('ytdlp:startUpdate', async () => { } catch (err) { const message = err instanceof Error ? err.message : String(err); log('[ytdlp-update] update failed:', message); - send('error'); + send('error', { verificationFailure: err?.code === YTDLP_VERIFICATION_ERROR_CODE }); throw err instanceof Error ? err : new Error(message); } }); diff --git a/src/electron/preload.test.mjs b/src/electron/preload.test.mjs index 9d88cec..e0f6088 100644 --- a/src/electron/preload.test.mjs +++ b/src/electron/preload.test.mjs @@ -111,8 +111,8 @@ describe('electronAPI event subscriptions', () => { const callback = vi.fn(); electronAPI.onYtdlpUpdateProgress(callback); expect(on).toHaveBeenCalledWith('ytdlpUpdateProgress', expect.any(Function)); - on.mock.calls[0][1](/* _event */ {}, { stage: 'building' }); - expect(callback).toHaveBeenCalledWith({ stage: 'building' }); + on.mock.calls[0][1](/* _event */ {}, { stage: 'installing' }); + expect(callback).toHaveBeenCalledWith({ stage: 'installing' }); removeAllListeners.mockClear(); electronAPI.removeYtdlpUpdateProgressListener(); diff --git a/src/electron/updater.mjs b/src/electron/updater.mjs index 103db87..06bd794 100644 --- a/src/electron/updater.mjs +++ b/src/electron/updater.mjs @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import https from 'https'; import { spawn } from 'child_process'; +import { resolveLatestRelease, mapPlatformToAssetName, detectMusl, fetchAndVerifyRelease } from './ytdlpRelease.mjs'; // Every command below talks to the network (PyPI, GitHub); a stalled // connection (a dropped packet with no RST, a proxy/AV product holding the @@ -339,13 +340,22 @@ async function verifyAndSwap({ builtDir, liveDir, binaryName, onLog }) { return verify.stdout.trim(); } +// This Python/PyInstaller update path is retired -- no longer called by +// main.mjs's ytdlp:startUpdate handler, which now calls performYtdlpUpdate +// (below, the former performYtdlpUpdateViaGitHub) instead. Kept here, fully +// intact and still independently testable, only until one full shipped +// release cycle has run on the new path with no reported issues -- at which +// point this, ensurePythonRuntime, ensurePyinstaller, rebuildYtdlp, +// findPythonRuntimeAsset, pythonExePath, and the other Python-era helpers +// above all get deleted together. +// // onLog is optional (main.mjs passes its own log() -> userData/main.log, // already viewable via Options -> "Open error log") -- every step below logs // the command it's about to run and how it ended (including a timeout, see // run()/probe()/fetchJson()/downloadFile() above), so a stuck or failed // update actually leaves a trail instead of nothing -- an installed app has // no console to watch otherwise. -export async function performYtdlpUpdate({ userDataDir, pythonSrcDir, liveYtdlpBinDir, ytdlpBinaryName, isDownloadActive, onProgress, onLog }) { +export async function performYtdlpUpdateLegacy({ userDataDir, pythonSrcDir, liveYtdlpBinDir, ytdlpBinaryName, isDownloadActive, onProgress, onLog }) { if (isDownloadActive && isDownloadActive()) { throw new Error('A download is currently in progress. Finish it before applying a yt-dlp update.'); } @@ -370,3 +380,45 @@ export async function performYtdlpUpdate({ userDataDir, pythonSrcDir, liveYtdlpB onLog?.(`[ytdlp-update] update complete -> ${newVersion}`); return { version: newVersion }; } + +// Replacement for performYtdlpUpdateLegacy above: fetches and GPG-verifies +// yt-dlp's own official release binary (see ytdlpRelease.mjs) instead of +// rebuilding it locally from unpinned pip installs. This is what main.mjs's +// ytdlp:startUpdate handler actually calls now. +// +// isDownloadActive/onProgress/onLog carry the exact same meaning as +// performYtdlpUpdateLegacy's own -- a download in progress still blocks +// starting an update (and still aborts one already in flight if a download +// starts mid-way), and progress/log callbacks still exist so the renderer's +// overlay and userData/main.log stay informative. verifyAndSwap is reused +// completely unchanged: it was always generic over "a directory containing +// a working binary," regardless of how that directory was produced. +export async function performYtdlpUpdate({ userDataDir, liveYtdlpBinDir, ytdlpBinaryName, isDownloadActive, onProgress, onLog }) { + if (isDownloadActive && isDownloadActive()) { + throw new Error('A download is currently in progress. Finish it before applying a yt-dlp update.'); + } + + onProgress?.('checking'); + onLog?.('[ytdlp-update] starting update (GitHub release)'); + const workDir = path.join(userDataDir, 'ytdlp-update-work'); + + const release = await resolveLatestRelease(); + const assetName = mapPlatformToAssetName({ isMusl: detectMusl() }); + onLog?.(`[ytdlp-update] resolved latest release ${release.tag}, asset ${assetName}`); + + // fetchAndVerifyRelease itself emits 'fetching' -> 'verifying' -> + // 'installing' as it downloads, checksum/signature-verifies, and + // unzips -- no separate onProgress calls needed for that part here. + const extractedDir = await fetchAndVerifyRelease({ release, assetName, workDir, onProgress, onLog }); + + if (isDownloadActive && isDownloadActive()) { + throw new Error('A download started while the update was downloading. Finish it, then try applying the update again.'); + } + + const newVersion = await verifyAndSwap({ builtDir: extractedDir, liveDir: liveYtdlpBinDir, binaryName: ytdlpBinaryName, onLog }); + + fs.rmSync(workDir, { recursive: true, force: true }); + onProgress?.('done'); + onLog?.(`[ytdlp-update] update complete -> ${newVersion}`); + return { version: newVersion }; +} diff --git a/src/electron/ytdlp-public-key.asc b/src/electron/ytdlp-public-key.asc new file mode 100644 index 0000000..b3af31e --- /dev/null +++ b/src/electron/ytdlp-public-key.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGP78C4BEAD0rF9zjGPAt0thlt5C1ebzccAVX7Nb1v+eqQjk+WEZdTETVCg3 +WAM5ngArlHdm/fZqzUgO+pAYrB60GKeg7ffUDf+S0XFKEZdeRLYeAaqqKhSibVal +DjvOBOztu3W607HLETQAqA7wTPuIt2WqmpL60NIcyr27LxqmgdN3mNvZ2iLO+bP0 +nKR/C+PgE9H4ytywDa12zMx6PmZCnVOOOu6XZEFmdUxxdQ9fFDqd9LcBKY2LDOcS +Yo1saY0YWiZWHtzVoZu1kOzjnS5Fjq/yBHJLImDH7pNxHm7s/PnaurpmQFtDFruk +t+2lhDnpKUmGr/I/3IHqH/X+9nPoS4uiqQ5HpblB8BK+4WfpaiEg75LnvuOPfZIP +KYyXa/0A7QojMwgOrD88ozT+VCkKkkJ+ijXZ7gHNjmcBaUdKK7fDIEOYI63Lyc6Q +WkGQTigFffSUXWHDCO9aXNhP3ejqFWgGMtCUsrbkcJkWuWY7q5ARy/05HbSM3K4D +U9eqtnxmiV1WQ8nXuI9JgJQRvh5PTkny5LtxqzcmqvWO9TjHBbrs14BPEO9fcXxK +L/CFBbzXDSvvAgArdqqlMoncQ/yicTlfL6qzJ8EKFiqW14QMTdAn6SuuZTodXCTi +InwoT7WjjuFPKKdvfH1GP4bnqdzTnzLxCSDIEtfyfPsIX+9GI7Jkk/zZjQARAQAB +tDdTaW1vbiBTYXdpY2tpICh5dC1kbHAgc2lnbmluZyBrZXkpIDxjb250YWN0QGdy +dWI0ay54eXo+iQJOBBMBCgA4FiEErAy75oSNaoc0ZK9OV89lkztadYEFAmP78C4C +GwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQV89lkztadYEVqQ//cW7TxhXg +7Xbh2EZQzXml0egn6j8QaV9KzGragMiShrlvTO2zXfLXqyizrFP4AspgjSn/4NrI +8mluom+Yi+qr7DXT4BjQqIM9y3AjwZPdywe912Lxcw52NNoPZCm24I9T7ySc8lmR +FQvZC0w4H/VTNj/2lgJ1dwMflpwvNRiWa5YzcFGlCUeDIPskLx9++AJE+xwU3LYm +jQQsPBqpHHiTBEJzMLl+rfd9Fg4N+QNzpFkTDW3EPerLuvJniSBBwZthqxeAtw4M +UiAXh6JvCc2hJkKCoygRfM281MeolvmsGNyQm+axlB0vyldiPP6BnaRgZlx+l6MU +cPqgHblb7RW5j9lfr6OYL7SceBIHNv0CFrt1OnkGo/tVMwcs8LH3Ae4a7UJlIceL +V54aRxSsZU7w4iX+PB79BWkEsQzwKrUuJVOeL4UDwWajp75OFaUqbS/slDDVXvK5 +OIeuth3mA/adjdvgjPxhRQjA3l69rRWIJDrqBSHldmRsnX6cvXTDy8wSXZgy51lP +m4IVLHnCy9m4SaGGoAsfTZS0cC9FgjUIyTyrq9M67wOMpUxnuB0aRZgJE1DsI23E +qdvcSNVlO+39xM/KPWUEh6b83wMn88QeW+DCVGWACQq5N3YdPnAJa50617fGbY6I +gXIoRHXkDqe23PZ/jURYCv0sjVtjPoVC+bg= +=bJkn +-----END PGP PUBLIC KEY BLOCK----- diff --git a/src/electron/ytdlpRelease.mjs b/src/electron/ytdlpRelease.mjs new file mode 100644 index 0000000..eafd3ae --- /dev/null +++ b/src/electron/ytdlpRelease.mjs @@ -0,0 +1,537 @@ +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import zlib from 'zlib'; +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; + +// Zero third-party dependencies on purpose -- this module is imported both +// by updater.mjs (which runs inside the packaged app, where node_modules is +// deliberately not shipped, see copy-electron.mjs's own comment on that) and +// by scripts/fetch-ytdlp-bin.mjs (a plain pre-build Node script). Only Node +// built-ins, no PATH-resolved external tools (no shelling out to `tar`/`gpg` +// -- that's exactly the "resolved from PATH" pattern this module exists to +// stop doing for yt-dlp's own binary). + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// yt-dlp's real signing key, fetched once by hand from +// https://raw.githubusercontent.com/yt-dlp/yt-dlp/master/public.key and +// committed here -- never fetched over the network at verification time. +// Verifying a signature with a key pulled from the same channel the +// signature is supposed to be defending would defeat the entire point. +export const YTDLP_PUBLIC_KEY_ARMORED = fs.readFileSync(path.join(__dirname, 'ytdlp-public-key.asc'), 'utf-8'); + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; + +// Distinguishes "the downloaded release didn't match its own published +// checksum/signature" from every other failure mode in fetchAndVerifyRelease +// (network errors, timeouts, a malformed SHA2-256SUMS format) -- callers use +// this to tell the user the problem is with the external yt-dlp release +// itself, not with this app, without having to pattern-match error text. +export const YTDLP_VERIFICATION_ERROR_CODE = 'YTDLP_VERIFICATION_FAILED'; + +function verificationError(message) { + const err = new Error(message); + err.code = YTDLP_VERIFICATION_ERROR_CODE; + return err; +} + +// Same hang-safety shape as updater.mjs's own fetchJson/downloadFile (kept +// as independent copies here rather than imported -- this module has to +// keep working standalone while updater.mjs's old Python-based update path +// is still present alongside it, unrelated to it, until it's removed). +function fetchJson(url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + fetchJson(res.headers.location, { timeoutMs }).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Request to ${url} failed with status ${res.statusCode}`)); + res.resume(); + return; + } + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error(`Failed to parse JSON from ${url}`)); + } + }); + }).on('error', reject); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request to ${url} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }); + }); +} + +function downloadFile(url, destPath, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + downloadFile(res.headers.location, destPath, { timeoutMs }).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Download from ${url} failed with status ${res.statusCode}`)); + res.resume(); + return; + } + const fileStream = fs.createWriteStream(destPath); + res.pipe(fileStream); + fileStream.on('finish', () => fileStream.close(() => resolve())); + fileStream.on('error', reject); + }).on('error', reject); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Download from ${url} timed out after ${Math.round(timeoutMs / 1000)}s of inactivity`)); + }); + }); +} + +function downloadText(url, opts) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + downloadText(res.headers.location, opts).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Request to ${url} failed with status ${res.statusCode}`)); + res.resume(); + return; + } + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve(data)); + }).on('error', reject); + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request to ${url} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }); + }); +} + +// yt-dlp's official onedir .zip assets, per platform/arch -- deliberately +// never the bare (non-.zip) onefile names. The bare `yt-dlp_macos`/ +// `yt-dlp_linux`/`yt-dlp.exe` files are PyInstaller onefile builds that +// self-extract to a fresh temp directory on every single launch, which is +// exactly the regression this project already hit once and reverted from +// when it previously tried fetching yt-dlp's own binary this same way. The +// .zip variants unpack to the same --onedir layout (a launcher plus an +// _internal/ folder) this project's own build already produces -- extracted +// once, not per launch. +// Also deliberately never `yt-dlp` (the Unix zipimport binary) or +// `yt-dlp_x86` (32-bit Windows) -- yt-dlp's own docs confirm curl_cffi +// (browser-impersonation, required outright for Dailymotion and used +// unconditionally by Instagram/TikTok) is bundled in every build except +// those two. +export function mapPlatformToAssetName({ platform = process.platform, arch = process.arch, isMusl = false } = {}) { + if (platform === 'darwin') { + // One universal2 build covers both Apple Silicon and Intel. + return 'yt-dlp_macos.zip'; + } + if (platform === 'win32') { + if (arch === 'arm64') return 'yt-dlp_win_arm64.zip'; + if (arch === 'ia32') return 'yt-dlp_win_x86.zip'; + if (arch === 'x64') return 'yt-dlp_win.zip'; + throw new Error(`Unsupported Windows architecture: ${arch}`); + } + if (platform === 'linux') { + if (arch === 'arm64') return isMusl ? 'yt-dlp_musllinux_aarch64.zip' : 'yt-dlp_linux_aarch64.zip'; + if (arch === 'x64') return isMusl ? 'yt-dlp_musllinux.zip' : 'yt-dlp_linux.zip'; + throw new Error(`Unsupported Linux architecture: ${arch}`); + } + throw new Error(`Unsupported platform: ${platform}`); +} + +// Best-effort libc probe. Node's own diagnostic reporting populates +// glibcVersionRuntime only on a real glibc host -- its absence on Linux +// implies musl (e.g. Alpine), but this is a heuristic, not authoritative; +// callers can always override via mapPlatformToAssetName's own isMusl param. +export function detectMusl() { + if (process.platform !== 'linux') return false; + try { + const header = process.report?.getReport?.()?.header; + return !header || header.glibcVersionRuntime === undefined; + } catch { + return false; + } +} + +// With `pin`, resolves to that exact tag with no network call -- what the +// build script uses for a reproducible, pinned SlothArchiver release. Without +// it, resolves yt-dlp's real latest GitHub release once. Callers must call +// this exactly once per operation and thread the single result through every +// subsequent step (the asset URL, the SHA2-256SUMS URL, the .sig URL) -- +// resolving "latest" more than once per operation is what let the old +// PyPI-vs-pip-install approach drift to two different versions mid-update. +export async function resolveLatestRelease({ pin } = {}) { + if (pin) { + return { tag: pin }; + } + const release = await fetchJson('https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest'); + return { tag: release.tag_name, assets: release.assets }; +} + +function assetDownloadUrl(tag, assetName) { + return `https://github.com/yt-dlp/yt-dlp/releases/download/${tag}/${assetName}`; +} + +// --- Minimal OpenPGP-subset verification ----------------------------------- +// Scoped to exactly what yt-dlp's own release signing actually uses -- +// confirmed by hand-decoding their real key and a real release signature: +// an old-format RSA public-key packet (tag 6) and an old-format v4 RSA +// signature packet (tag 2, binary-document type 0x00, SHA-256 or SHA-512). +// Nothing else is supported -- DSA/EdDSA/ECDSA, new-format packet headers, +// v3 signatures, text-mode signatures are all deliberately rejected rather +// than silently handled some other way. All real cryptography (hashing, RSA +// verification) is delegated entirely to Node's built-in `crypto`; only +// fixed-format binary parsing of two small packets is hand-rolled. + +function readOldFormatPacket(buf, offset) { + if (offset >= buf.length) throw new Error('Truncated OpenPGP packet'); + const first = buf[offset]; + if ((first & 0x80) === 0) throw new Error('Not an OpenPGP packet'); + if ((first & 0x40) !== 0) throw new Error('New-format OpenPGP packets are not supported'); + const tag = (first >> 2) & 0x0f; + const lengthType = first & 0x03; + let pos = offset + 1; + let length; + if (lengthType === 0) { length = buf[pos]; pos += 1; } + else if (lengthType === 1) { length = buf.readUInt16BE(pos); pos += 2; } + else if (lengthType === 2) { length = buf.readUInt32BE(pos); pos += 4; } + else throw new Error('Indeterminate-length OpenPGP packets are not supported'); + if (pos + length > buf.length) throw new Error('Truncated OpenPGP packet body'); + return { tag, body: buf.subarray(pos, pos + length), nextOffset: pos + length }; +} + +function readMpi(buf, offset) { + if (offset + 2 > buf.length) throw new Error('Truncated OpenPGP MPI'); + const bitLen = buf.readUInt16BE(offset); + const byteLen = Math.ceil(bitLen / 8); + if (offset + 2 + byteLen > buf.length) throw new Error('Truncated OpenPGP MPI data'); + return { data: buf.subarray(offset + 2, offset + 2 + byteLen), nextOffset: offset + 2 + byteLen }; +} + +function parsePublicKeyPacketBody(body) { + if (body.length < 6) throw new Error('Truncated OpenPGP public-key packet'); + if (body[0] !== 4) throw new Error('Only v4 OpenPGP public keys are supported'); + if (body[5] !== 1) throw new Error('Only RSA OpenPGP public keys are supported'); + const n = readMpi(body, 6); + const e = readMpi(body, n.nextOffset); + return { n: n.data, e: e.data }; +} + +function parseSignaturePacketBody(body) { + if (body.length < 6) throw new Error('Truncated OpenPGP signature packet'); + if (body[0] !== 4) throw new Error('Only v4 OpenPGP signatures are supported'); + const sigType = body[1]; + if (body[2] !== 1) throw new Error('Only RSA OpenPGP signatures are supported'); + const hashAlgo = body[3]; + const hashedLen = body.readUInt16BE(4); + if (6 + hashedLen > body.length) throw new Error('Truncated OpenPGP hashed subpacket data'); + let pos = 6 + hashedLen; + if (pos + 2 > body.length) throw new Error('Truncated OpenPGP unhashed subpacket length'); + const unhashedLen = body.readUInt16BE(pos); + pos += 2 + unhashedLen; + // Two-byte "left 16 bits of the signed hash" quick-check field -- not + // itself verified, just skipped over to reach the actual signature MPI. + pos += 2; + const mpi = readMpi(body, pos); + return { + sigType, + hashAlgo, + // The exact byte range RFC 4880 5.2.4 defines as covered by the + // signature: version..hashed-subpacket-data, with its own header. + hashedPortion: body.subarray(0, 6 + hashedLen), + signatureMpi: mpi.data, + }; +} + +function hashAlgoToNodeName(hashAlgo) { + if (hashAlgo === 8) return 'sha256'; + if (hashAlgo === 10) return 'sha512'; + throw new Error(`Unsupported OpenPGP hash algorithm ${hashAlgo}`); +} + +function parseArmoredPublicKey(armored) { + const lines = armored.split('\n'); + let started = false; + const bodyLines = []; + for (const line of lines) { + if (line.startsWith('-----BEGIN')) { started = true; continue; } + if (line.startsWith('-----END')) break; + if (!started) continue; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('=')) continue; // blank lines, header lines, CRC24 checksum + bodyLines.push(trimmed); + } + if (!bodyLines.length) throw new Error('No OpenPGP public key data found in armored block'); + return Buffer.from(bodyLines.join(''), 'base64'); +} + +function base64Url(buf) { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// Verifies `signature` (a raw, non-armored, detached OpenPGP signature +// packet -- exactly the shape of a downloaded `SHA2-256SUMS.sig`) is a valid +// signature over `data`, made by the RSA key in `publicKeyArmored`. Returns +// a boolean; never returns true on anything it can't fully parse and +// validate -- a parse failure throws (treated as "not verified" by every +// caller here), never silently resolves to a pass. +export function verifyDetachedSignature({ data, signature, publicKeyArmored }) { + const keyBuf = parseArmoredPublicKey(publicKeyArmored); + const keyPacket = readOldFormatPacket(keyBuf, 0); + if (keyPacket.tag !== 6) throw new Error('Expected an OpenPGP public-key packet'); + const { n, e } = parsePublicKeyPacketBody(keyPacket.body); + + const sigPacket = readOldFormatPacket(signature, 0); + if (sigPacket.tag !== 2) throw new Error('Expected an OpenPGP signature packet'); + const { sigType, hashAlgo, hashedPortion, signatureMpi } = parseSignaturePacketBody(sigPacket.body); + if (sigType !== 0x00) throw new Error('Expected a binary-document OpenPGP signature'); + + // The v4 signature trailer: a fixed 0x04 0xFF marker, then the + // hashed-portion length as a big-endian uint32. + const trailer = Buffer.from([0x04, 0xff, 0, 0, 0, 0]); + trailer.writeUInt32BE(hashedPortion.length, 2); + const dataToVerify = Buffer.concat([data, hashedPortion, trailer]); + + const publicKey = crypto.createPublicKey({ + key: { kty: 'RSA', n: base64Url(n), e: base64Url(e) }, + format: 'jwk', + }); + + return crypto.verify( + hashAlgoToNodeName(hashAlgo), + dataToVerify, + { key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + signatureMpi, + ); +} + +// --- Minimal ZIP reader ------------------------------------------------------ +// Scoped to exactly what a GitHub-Releases-hosted, PyInstaller-onedir build +// actually produces: single-disk, under 100MB, "stored" (0) or "deflate" (8) +// compression only, no encryption. Confirmed directly against the real +// yt-dlp_macos.zip asset that these official builds contain zero symlinks +// (unlike this project's own local PyInstaller onedir output, which needs +// dereferencing) -- so no symlink handling here on purpose; if a future +// yt-dlp release ever changes that, extraction will fail loudly (an +// unhandled entry type) rather than silently mis-copy a symlink as a file. + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_DIR_SIGNATURE = 0x02014b50; +const LOCAL_FILE_SIGNATURE = 0x04034b50; +// The EOCD record is fixed-size (22 bytes) plus a variable-length comment +// (max 65535 bytes) -- search backward from the end for its signature, +// bounded so a corrupt/non-zip file fails fast instead of scanning forever. +const EOCD_MIN_SIZE = 22; +const MAX_COMMENT_SIZE = 65535; + +function findEndOfCentralDirectory(buf) { + const searchStart = Math.max(0, buf.length - EOCD_MIN_SIZE - MAX_COMMENT_SIZE); + for (let i = buf.length - EOCD_MIN_SIZE; i >= searchStart; i--) { + if (buf.readUInt32LE(i) === EOCD_SIGNATURE) return i; + } + throw new Error('Not a valid ZIP file: end-of-central-directory record not found'); +} + +export function unzip(zipPath, destDir) { + const buf = fs.readFileSync(zipPath); + const eocdOffset = findEndOfCentralDirectory(buf); + const entryCount = buf.readUInt16LE(eocdOffset + 10); + const centralDirOffset = buf.readUInt32LE(eocdOffset + 16); + + let pos = centralDirOffset; + for (let i = 0; i < entryCount; i++) { + if (buf.readUInt32LE(pos) !== CENTRAL_DIR_SIGNATURE) { + throw new Error(`Corrupt ZIP central directory at entry ${i}`); + } + const versionMadeBy = buf.readUInt16LE(pos + 4); + const compressionMethod = buf.readUInt16LE(pos + 10); + const compressedSize = buf.readUInt32LE(pos + 20); + const uncompressedSize = buf.readUInt32LE(pos + 24); + const nameLen = buf.readUInt16LE(pos + 28); + const extraLen = buf.readUInt16LE(pos + 30); + const commentLen = buf.readUInt16LE(pos + 32); + const externalAttrs = buf.readUInt32LE(pos + 38); + const localHeaderOffset = buf.readUInt32LE(pos + 42); + const name = buf.toString('utf-8', pos + 46, pos + 46 + nameLen); + pos += 46 + nameLen + extraLen + commentLen; + + // Reject path traversal in the entry name outright -- a malicious or + // corrupt archive could otherwise write outside destDir. + const destPath = path.join(destDir, name); + if (path.relative(destDir, destPath).startsWith('..')) { + throw new Error(`ZIP entry escapes destination directory: ${name}`); + } + + if (name.endsWith('/')) { + fs.mkdirSync(destPath, { recursive: true }); + continue; + } + + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + + // Local file header's own name/extra-field lengths can differ from + // the central directory's (rare in practice, but the format allows + // it) -- read them fresh rather than assuming the central directory's + // values apply to the local header's layout too. + const lh = localHeaderOffset; + if (buf.readUInt32LE(lh) !== LOCAL_FILE_SIGNATURE) { + throw new Error(`Corrupt ZIP local file header for ${name}`); + } + const lhNameLen = buf.readUInt16LE(lh + 26); + const lhExtraLen = buf.readUInt16LE(lh + 28); + const dataStart = lh + 30 + lhNameLen + lhExtraLen; + const compressedData = buf.subarray(dataStart, dataStart + compressedSize); + + let fileData; + if (compressionMethod === 0) { + fileData = compressedData; + } else if (compressionMethod === 8) { + fileData = zlib.inflateRawSync(compressedData); + } else { + throw new Error(`Unsupported ZIP compression method ${compressionMethod} for ${name}`); + } + if (fileData.length !== uncompressedSize) { + throw new Error(`Size mismatch extracting ${name}: expected ${uncompressedSize}, got ${fileData.length}`); + } + fs.writeFileSync(destPath, fileData); + + // External attributes' upper 16 bits are the Unix mode when the + // archive was made on a Unix host (version-made-by's high byte 3) -- + // restore it so the launcher executable comes out executable. + const hostOs = versionMadeBy >> 8; + if (hostOs === 3) { + const unixMode = (externalAttrs >>> 16) & 0o7777; + if (unixMode) { + try { + fs.chmodSync(destPath, unixMode); + } catch { + // Best-effort -- explicit chmod of the launcher below + // covers the one file that actually needs +x. + } + } + } + } +} + +// Parses a `SHA2-256SUMS`-formatted string (` ` per +// line) and returns the hex digest for one specific filename, or null if +// that filename has no line. +export function findChecksumForAsset(sumsText, assetName) { + for (const line of sumsText.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = /^([0-9a-fA-F]+)\s+\*?(.+)$/.exec(trimmed); + if (match && match[2] === assetName) return match[1].toLowerCase(); + } + return null; +} + +// The orchestration entry point: downloads assetName plus SHA2-256SUMS and +// SHA2-256SUMS.sig from `release` (as returned by resolveLatestRelease), +// verifies the asset's SHA-256 against its line in SHA2-256SUMS, verifies +// SHA2-256SUMS's own signature against the vendored public key, and only on +// full success unzips the asset into a fresh subdirectory of workDir. +// +// Fails closed on purpose, unlike yt-dlp's own `-U` (which proceeds with an +// unverified download if SHA2-256SUMS is simply unavailable) -- any missing +// asset, missing checksum line, missing/invalid signature, or hash mismatch +// throws immediately and nothing gets extracted. +export async function fetchAndVerifyRelease({ release, assetName, workDir, publicKeyArmored = YTDLP_PUBLIC_KEY_ARMORED, onProgress, onLog }) { + fs.mkdirSync(workDir, { recursive: true }); + const assetPath = path.join(workDir, assetName); + + onProgress?.('fetching'); + onLog?.(`[ytdlp-release] downloading ${assetName} (${release.tag})`); + await downloadFile(assetDownloadUrl(release.tag, assetName), assetPath); + + // TEMPORARY manual test hook -- flips one byte of the just-downloaded + // asset so the checksum step below is exercised against real tampered + // data, proving the fail-closed path actually rejects and refuses to + // install rather than just being untested code. Inert unless the env var + // is set. Revert this block once the manual test is done. + if (process.env.SLOTH_DEBUG_TAMPER_YTDLP) { + const tampered = fs.readFileSync(assetPath); + tampered[0] ^= 0xff; + fs.writeFileSync(assetPath, tampered); + onLog?.('[ytdlp-release] TEST: tampered with downloaded asset on purpose'); + } + + onLog?.('[ytdlp-release] downloading SHA2-256SUMS and SHA2-256SUMS.sig'); + const [sumsText, sigBase64OrBinary] = await Promise.all([ + downloadText(assetDownloadUrl(release.tag, 'SHA2-256SUMS')), + new Promise((resolve, reject) => { + const sigPath = path.join(workDir, 'SHA2-256SUMS.sig'); + downloadFile(assetDownloadUrl(release.tag, 'SHA2-256SUMS.sig'), sigPath) + .then(() => resolve(fs.readFileSync(sigPath))) + .catch(reject); + }), + ]); + + onProgress?.('verifying'); + const expectedChecksum = findChecksumForAsset(sumsText, assetName); + if (!expectedChecksum) { + throw verificationError(`SHA2-256SUMS has no entry for ${assetName} -- refusing to install an unverified binary`); + } + + const sumsBuffer = Buffer.from(sumsText, 'utf-8'); + let signatureValid; + try { + signatureValid = verifyDetachedSignature({ data: sumsBuffer, signature: sigBase64OrBinary, publicKeyArmored }); + } catch (err) { + throw verificationError(`Failed to verify SHA2-256SUMS's signature: ${err.message}`); + } + if (!signatureValid) { + throw verificationError("SHA2-256SUMS's GPG signature is invalid -- refusing to install an unverified binary"); + } + onLog?.('[ytdlp-release] SHA2-256SUMS signature verified'); + + const actualChecksum = crypto.createHash('sha256').update(fs.readFileSync(assetPath)).digest('hex'); + if (actualChecksum !== expectedChecksum) { + throw verificationError(`Checksum mismatch for ${assetName}: expected ${expectedChecksum}, got ${actualChecksum}`); + } + onLog?.(`[ytdlp-release] ${assetName} checksum verified`); + + onProgress?.('installing'); + const extractDir = path.join(workDir, 'extracted'); + fs.rmSync(extractDir, { recursive: true, force: true }); + fs.mkdirSync(extractDir, { recursive: true }); + unzip(assetPath, extractDir); + + // yt-dlp's own onedir assets name the launcher after the asset itself + // (e.g. yt-dlp_macos.zip's launcher is literally called `yt-dlp_macos`, + // confirmed against the real asset), not the fixed `yt-dlp`/`yt-dlp.exe` + // name every spawn call site in this app expects (main.mjs's + // ytdlpBinaryName). Rename it into place here, once, so every caller of + // fetchAndVerifyRelease gets a directory shaped exactly like this + // project's own previous local PyInstaller build already was. + renameLauncherToCanonicalName(extractDir, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'); + + return extractDir; +} + +// The onedir layout is always exactly one launcher executable plus one +// `_internal/` directory at the top level (confirmed against the real +// yt-dlp_macos.zip asset: 162 entries total, but exactly 2 at the top +// level). Finds that one launcher -- whatever it's actually named -- and +// renames it to `canonicalName`, rather than guessing a naming pattern per +// asset variant. Throws if the directory doesn't have exactly that shape, +// so a future change to yt-dlp's own packaging fails loudly here instead of +// silently shipping a binary at the wrong path. +export function renameLauncherToCanonicalName(dir, canonicalName) { + const entries = fs.readdirSync(dir).filter((name) => name !== '_internal'); + if (entries.length !== 1) { + throw new Error(`Expected exactly one launcher executable alongside _internal/ in ${dir}, found: ${entries.join(', ') || '(none)'}`); + } + const [launcherName] = entries; + if (launcherName === canonicalName) return; + fs.renameSync(path.join(dir, launcherName), path.join(dir, canonicalName)); +} diff --git a/src/electron/ytdlpRelease.test.mjs b/src/electron/ytdlpRelease.test.mjs new file mode 100644 index 0000000..24b3bbd --- /dev/null +++ b/src/electron/ytdlpRelease.test.mjs @@ -0,0 +1,389 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import { EventEmitter } from 'events'; +import { + mapPlatformToAssetName, + detectMusl, + verifyDetachedSignature, + findChecksumForAsset, + unzip, + renameLauncherToCanonicalName, + YTDLP_PUBLIC_KEY_ARMORED, +} from './ytdlpRelease.mjs'; + +const fixturesDir = path.join(import.meta.dirname, '__fixtures__', 'ytdlp-release'); +// Real assets captured from yt-dlp's own 2026.08.19 GitHub release -- never +// fetched live in a test run (offline, fast, deterministic). See +// ytdlpRelease.mjs's own header comment for why the public key these verify +// against is committed rather than fetched at verification time. +const realSums = fs.readFileSync(path.join(fixturesDir, 'SHA2-256SUMS'), 'utf-8'); +const realSig = fs.readFileSync(path.join(fixturesDir, 'SHA2-256SUMS.sig')); + +describe('mapPlatformToAssetName', () => { + it.each([ + [{ platform: 'darwin', arch: 'arm64' }, 'yt-dlp_macos.zip'], + [{ platform: 'darwin', arch: 'x64' }, 'yt-dlp_macos.zip'], // universal2 -- one asset for both + [{ platform: 'win32', arch: 'x64' }, 'yt-dlp_win.zip'], + [{ platform: 'win32', arch: 'arm64' }, 'yt-dlp_win_arm64.zip'], + [{ platform: 'win32', arch: 'ia32' }, 'yt-dlp_win_x86.zip'], + [{ platform: 'linux', arch: 'x64' }, 'yt-dlp_linux.zip'], + [{ platform: 'linux', arch: 'arm64' }, 'yt-dlp_linux_aarch64.zip'], + [{ platform: 'linux', arch: 'x64', isMusl: true }, 'yt-dlp_musllinux.zip'], + [{ platform: 'linux', arch: 'arm64', isMusl: true }, 'yt-dlp_musllinux_aarch64.zip'], + ])('%j -> %s', (input, expected) => { + expect(mapPlatformToAssetName(input)).toBe(expected); + }); + + it('never returns a bare onefile name or a curl_cffi-less variant', () => { + const allAssets = [ + mapPlatformToAssetName({ platform: 'darwin', arch: 'arm64' }), + mapPlatformToAssetName({ platform: 'win32', arch: 'x64' }), + mapPlatformToAssetName({ platform: 'linux', arch: 'x64' }), + ]; + for (const asset of allAssets) { + expect(asset.endsWith('.zip')).toBe(true); + expect(asset).not.toBe('yt-dlp'); + expect(asset).not.toBe('yt-dlp_x86.exe'); + } + }); + + it.each([ + ['linux', 'ia32'], + ['win32', 'mips'], + ['freebsd', 'x64'], + ])('throws for unsupported %s/%s', (platform, arch) => { + expect(() => mapPlatformToAssetName({ platform, arch })).toThrow(); + }); +}); + +describe('detectMusl', () => { + function withPlatform(platform, fn) { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + return fn(); + } finally { + Object.defineProperty(process, 'platform', original); + } + } + + function withReport(report, fn) { + const original = Object.getOwnPropertyDescriptor(process, 'report'); + Object.defineProperty(process, 'report', { value: report, configurable: true }); + try { + return fn(); + } finally { + if (original) Object.defineProperty(process, 'report', original); + else delete process.report; + } + } + + it('is false on non-Linux platforms regardless of process.report', () => { + withPlatform('darwin', () => { + withReport({ getReport: () => ({ header: {} }) }, () => { + expect(detectMusl()).toBe(false); + }); + }); + }); + + it('is false on Linux when glibcVersionRuntime is present (a real glibc host)', () => { + withPlatform('linux', () => { + withReport({ getReport: () => ({ header: { glibcVersionRuntime: '2.35' } }) }, () => { + expect(detectMusl()).toBe(false); + }); + }); + }); + + it('is true on Linux when glibcVersionRuntime is absent (implies musl)', () => { + withPlatform('linux', () => { + withReport({ getReport: () => ({ header: {} }) }, () => { + expect(detectMusl()).toBe(true); + }); + }); + }); +}); + +describe('resolveLatestRelease', () => { + it('returns the pinned tag with no network call', async () => { + const httpsGet = vi.fn(); + vi.doMock('https', () => ({ default: { get: httpsGet } })); + vi.resetModules(); + const { resolveLatestRelease: freshResolve } = await import('./ytdlpRelease.mjs'); + + await expect(freshResolve({ pin: '2026.08.19' })).resolves.toEqual({ tag: '2026.08.19' }); + expect(httpsGet).not.toHaveBeenCalled(); + + vi.doUnmock('https'); + vi.resetModules(); + }); + + it('fetches and returns the real latest release tag/assets when unpinned', async () => { + vi.doMock('https', () => ({ + default: { + get: (url, options, callback) => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = {}; + res.resume = vi.fn(); + callback(res); + queueMicrotask(() => { + res.emit('data', JSON.stringify({ tag_name: '2026.08.19', assets: [{ name: 'yt-dlp_macos.zip' }] })); + res.emit('end'); + }); + const req = new EventEmitter(); + req.setTimeout = vi.fn(); + return req; + }, + }, + })); + vi.resetModules(); + const { resolveLatestRelease: freshResolve } = await import('./ytdlpRelease.mjs'); + + await expect(freshResolve()).resolves.toEqual({ + tag: '2026.08.19', + assets: [{ name: 'yt-dlp_macos.zip' }], + }); + + vi.doUnmock('https'); + vi.resetModules(); + }); +}); + +describe('findChecksumForAsset', () => { + it('finds the checksum line for a real asset name', () => { + expect(findChecksumForAsset(realSums, 'yt-dlp_macos.zip')).toBe('07e54b0865303c864006925913bce2604f8ee8cc6f18699bac9c309f9328a6d8'); + }); + + it('returns null for a filename with no matching line', () => { + expect(findChecksumForAsset(realSums, 'not-a-real-asset')).toBeNull(); + }); +}); + +describe('verifyDetachedSignature', () => { + it('accepts the real signature over the real data with the real key', () => { + expect(verifyDetachedSignature({ data: Buffer.from(realSums), signature: realSig, publicKeyArmored: YTDLP_PUBLIC_KEY_ARMORED })).toBe(true); + }); + + it('rejects when the signed data has been tampered with', () => { + const tampered = Buffer.from(realSums); + tampered[0] ^= 0xff; + expect(verifyDetachedSignature({ data: tampered, signature: realSig, publicKeyArmored: YTDLP_PUBLIC_KEY_ARMORED })).toBe(false); + }); + + it('rejects a corrupted signature rather than throwing or accepting it', () => { + const corrupted = Buffer.from(realSig); + corrupted[300] ^= 0xff; + expect(verifyDetachedSignature({ data: Buffer.from(realSums), signature: corrupted, publicKeyArmored: YTDLP_PUBLIC_KEY_ARMORED })).toBe(false); + }); + + it('throws on a truncated signature rather than silently accepting it', () => { + expect(() => verifyDetachedSignature({ + data: Buffer.from(realSums), + signature: realSig.subarray(0, 100), + publicKeyArmored: YTDLP_PUBLIC_KEY_ARMORED, + })).toThrow(); + }); + + it('rejects when verified against a different key', () => { + const wrongKey = YTDLP_PUBLIC_KEY_ARMORED.replace('mQINBGP78C4BEAD0', 'mQINBGP78C4BEAD1'); + expect(() => verifyDetachedSignature({ data: Buffer.from(realSums), signature: realSig, publicKeyArmored: wrongKey })).not.toThrow(); + expect(verifyDetachedSignature({ data: Buffer.from(realSums), signature: realSig, publicKeyArmored: wrongKey })).toBe(false); + }); +}); + +// A minimal, hand-built ZIP (not yt-dlp's real multi-MB asset -- that's +// exercised manually against a real download instead) covering exactly the +// two compression methods (stored, deflate) and the Unix-permission-bits +// path unzip() actually needs to handle, confirmed against yt-dlp's real +// asset this session. +function buildTestZip(entries) { + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf-8'); + const isDir = entry.name.endsWith('/'); + const rawContent = isDir ? Buffer.alloc(0) : Buffer.from(entry.content ?? '', 'utf-8'); + const method = entry.method ?? 8; // deflate by default + const compressed = isDir ? Buffer.alloc(0) : (method === 8 ? zlib.deflateRawSync(rawContent) : rawContent); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(isDir ? 0 : method, 8); + localHeader.writeUInt16LE(0, 10); // mod time + localHeader.writeUInt16LE(0, 12); // mod date + localHeader.writeUInt32LE(crc32(rawContent), 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(rawContent.length, 22); + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); // extra len + + localParts.push(localHeader, nameBuf, compressed); + + const mode = entry.mode ?? (isDir ? 0o40755 : 0o100644); + const externalAttrs = (mode << 16) >>> 0; + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE((3 << 8) | 20, 4); // version made by: unix host + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 8); // flags + centralHeader.writeUInt16LE(isDir ? 0 : method, 10); + centralHeader.writeUInt16LE(0, 12); // mod time + centralHeader.writeUInt16LE(0, 14); // mod date + centralHeader.writeUInt32LE(crc32(rawContent), 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(rawContent.length, 24); + centralHeader.writeUInt16LE(nameBuf.length, 28); + centralHeader.writeUInt16LE(0, 30); // extra len + centralHeader.writeUInt16LE(0, 32); // comment len + centralHeader.writeUInt16LE(0, 34); // disk number + centralHeader.writeUInt16LE(0, 36); // internal attrs + centralHeader.writeUInt32LE(externalAttrs, 38); + centralHeader.writeUInt32LE(offset, 42); + + centralParts.push(centralHeader, nameBuf); + + offset += localHeader.length + nameBuf.length + compressed.length; + } + + const centralDirStart = offset; + const centralDir = Buffer.concat(centralParts); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralDir.length, 12); + eocd.writeUInt32LE(centralDirStart, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDir, eocd]); +} + +// Node has no built-in CRC32 -- zlib exposes it only via the compression +// output's own trailer for gzip, not as a standalone primitive, so this +// implements the standard table-free bit-by-bit CRC32 (IEEE 802.3), used +// only by this test's own fixture builder. unzip() itself, per the real +// yt-dlp ZIP structure it's built against, never needs to *compute* a CRC -- +// only compressed/uncompressed size are checked (see its size-mismatch +// guard) -- so this stays test-only code, not something the production +// module carries. +function crc32(buf) { + let crc = ~0; + for (const byte of buf) { + crc ^= byte; + for (let i = 0; i < 8; i++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (~crc) >>> 0; +} + +describe('unzip', () => { + it('extracts stored and deflated files with correct content and permissions', () => { + const zipBuf = buildTestZip([ + { name: 'dir/', }, + { name: 'dir/stored.txt', content: 'hello stored', method: 0, mode: 0o100644 }, + { name: 'dir/deflated.txt', content: 'hello deflated, '.repeat(50), method: 8, mode: 0o100644 }, + { name: 'launcher', content: '#!/bin/sh\necho hi\n', method: 8, mode: 0o100755 }, + ]); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-unzip-')); + const zipPath = path.join(tmpDir, 'test.zip'); + const destDir = path.join(tmpDir, 'out'); + fs.writeFileSync(zipPath, zipBuf); + + try { + unzip(zipPath, destDir); + + expect(fs.readFileSync(path.join(destDir, 'dir', 'stored.txt'), 'utf-8')).toBe('hello stored'); + expect(fs.readFileSync(path.join(destDir, 'dir', 'deflated.txt'), 'utf-8')).toBe('hello deflated, '.repeat(50)); + expect(fs.readFileSync(path.join(destDir, 'launcher'), 'utf-8')).toBe('#!/bin/sh\necho hi\n'); + expect(fs.statSync(path.join(destDir, 'launcher')).mode & 0o777).toBe(0o755); + expect(fs.statSync(path.join(destDir, 'dir', 'stored.txt')).mode & 0o777).toBe(0o644); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects a ZIP entry that would escape the destination directory', () => { + const zipBuf = buildTestZip([ + { name: '../escape.txt', content: 'pwned', method: 0 }, + ]); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-unzip-escape-')); + const zipPath = path.join(tmpDir, 'test.zip'); + fs.writeFileSync(zipPath, zipBuf); + + try { + expect(() => unzip(zipPath, path.join(tmpDir, 'out'))).toThrow(/escapes destination/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('throws on a non-ZIP file rather than silently doing nothing', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-unzip-invalid-')); + const notAZip = path.join(tmpDir, 'not-a-zip.bin'); + fs.writeFileSync(notAZip, 'this is not a zip file'); + + try { + expect(() => unzip(notAZip, path.join(tmpDir, 'out'))).toThrow(/end-of-central-directory/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('renameLauncherToCanonicalName', () => { + it('renames the one non-_internal entry to the canonical name', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-rename-')); + try { + fs.mkdirSync(path.join(tmpDir, '_internal')); + fs.writeFileSync(path.join(tmpDir, 'yt-dlp_macos'), 'fake launcher'); + + renameLauncherToCanonicalName(tmpDir, 'yt-dlp'); + + expect(fs.existsSync(path.join(tmpDir, 'yt-dlp'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'yt-dlp_macos'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, '_internal'))).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('is a no-op when the launcher is already named canonically', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-rename-noop-')); + try { + fs.mkdirSync(path.join(tmpDir, '_internal')); + fs.writeFileSync(path.join(tmpDir, 'yt-dlp'), 'fake launcher'); + + renameLauncherToCanonicalName(tmpDir, 'yt-dlp'); + + expect(fs.existsSync(path.join(tmpDir, 'yt-dlp'))).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('throws rather than guessing when the directory shape is unexpected', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-rename-bad-')); + try { + fs.mkdirSync(path.join(tmpDir, '_internal')); + fs.writeFileSync(path.join(tmpDir, 'launcher-one'), 'x'); + fs.writeFileSync(path.join(tmpDir, 'launcher-two'), 'x'); + + expect(() => renameLauncherToCanonicalName(tmpDir, 'yt-dlp')).toThrow(/Expected exactly one launcher/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/ui/components/YtdlpUpdateDialog.test.tsx b/src/ui/components/YtdlpUpdateDialog.test.tsx index 5c3b199..62ab9ee 100644 --- a/src/ui/components/YtdlpUpdateDialog.test.tsx +++ b/src/ui/components/YtdlpUpdateDialog.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import YtdlpUpdateDialog from './YtdlpUpdateDialog'; import { YtdlpUpdaterProvider } from '../hooks/useYtdlpUpdater'; -let registeredCallback: ((data: { stage: string }) => void) | null = null; +let registeredCallback: ((data: { stage: string; verificationFailure?: boolean }) => void) | null = null; beforeEach(() => { registeredCallback = null; @@ -69,8 +69,8 @@ describe('YtdlpUpdateDialog', () => { await user.click(screen.getByRole('button', { name: 'Update' })); await waitFor(() => expect(screen.queryByText('yt-dlp update available')).not.toBeInTheDocument()); - act(() => registeredCallback?.({ stage: 'building' })); - await waitFor(() => expect(screen.getByText('Building (this can take a minute)...')).toBeInTheDocument()); + act(() => registeredCallback?.({ stage: 'installing' })); + await waitFor(() => expect(screen.getByText('Installing...')).toBeInTheDocument()); }); it('shows the failure overlay with Retry/Quit when the update fails', async () => { @@ -101,5 +101,23 @@ describe('YtdlpUpdateDialog', () => { // clickable elements that sit inside an aria-hidden ancestor like this. expect(screen.getByRole('button', { name: 'Retry', hidden: true })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Quit', hidden: true })).toBeInTheDocument(); + expect(screen.queryByText(/problem with yt-dlp's own release download/)).not.toBeInTheDocument(); + }); + + it('shows a verification-failure notice when the failure was a checksum/signature mismatch', async () => { + const user = userEvent.setup(); + (window.electronAPI.checkForYtdlpUpdate as ReturnType).mockResolvedValue({ + current: '2026.7.4', latest: '2026.7.5', updateAvailable: true, + }); + (window.electronAPI.startYtdlpUpdate as ReturnType).mockImplementation(async () => { + registeredCallback?.({ stage: 'error', verificationFailure: true }); + throw new Error('Checksum mismatch for yt-dlp_macos.zip: expected abc, got def'); + }); + renderDialog(); + await waitFor(() => expect(screen.getByText('yt-dlp update available')).toBeInTheDocument()); + + await user.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(screen.getByText('Update failed')).toBeInTheDocument()); + expect(screen.getByText(/problem with yt-dlp's own release download, not with this app/)).toBeInTheDocument(); }); }); diff --git a/src/ui/components/YtdlpUpdateDialog.tsx b/src/ui/components/YtdlpUpdateDialog.tsx index 29dd391..a557469 100644 --- a/src/ui/components/YtdlpUpdateDialog.tsx +++ b/src/ui/components/YtdlpUpdateDialog.tsx @@ -12,19 +12,10 @@ import { Stack, Typography, } from '@mui/material'; -import { useYtdlpUpdater, type YtdlpUpdateStage } from '../hooks/useYtdlpUpdater'; - -const IN_PROGRESS_STAGES = new Set([ - 'checking', - 'fetching-python-runtime', - 'installing-pyinstaller', - 'fetching-yt-dlp', - 'building', - 'verifying', -]); +import { useYtdlpUpdater, IN_PROGRESS_STAGES } from '../hooks/useYtdlpUpdater'; export default function YtdlpUpdateDialog() { - const { updateAvailable, currentVersion, latestVersion, stage, stageLabel, updateError, checkForUpdate, startUpdate, quit } = useYtdlpUpdater(); + const { updateAvailable, currentVersion, latestVersion, stage, stageLabel, updateError, verificationFailure, checkForUpdate, startUpdate, quit } = useYtdlpUpdater(); useEffect(() => { checkForUpdate(); @@ -54,8 +45,8 @@ export default function YtdlpUpdateDialog() { yt-dlp update available - A newer version of yt-dlp is available ({currentVersion} → {latestVersion}). Updating rebuilds yt-dlp - locally on this machine, which can take a minute or two the first time. + A newer version of yt-dlp is available ({currentVersion} → {latestVersion}). Updating downloads and + verifies yt-dlp's own signed release build -- this only takes a few seconds. yt-dlp is a required dependency -- if you cancel, the app will close. @@ -77,6 +68,13 @@ export default function YtdlpUpdateDialog() { Update failed {updateError} + {verificationFailure && + + This looks like a problem with yt-dlp's own release download, not with this app -- the downloaded + file didn't match yt-dlp's published checksum/signature. This is usually a corrupted download, so + retrying often fixes it; if it keeps happening, it may be worth checking yt-dlp's official release + page directly before retrying again. + } yt-dlp is a required dependency -- if you quit instead of retrying, the app will close. diff --git a/src/ui/hooks/useYtdlpUpdater.test.tsx b/src/ui/hooks/useYtdlpUpdater.test.tsx index 4add3e1..5377ef9 100644 --- a/src/ui/hooks/useYtdlpUpdater.test.tsx +++ b/src/ui/hooks/useYtdlpUpdater.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { YtdlpUpdaterProvider, useYtdlpUpdater, type YtdlpUpdateStage } from './useYtdlpUpdater'; -let registeredCallback: ((data: { stage: YtdlpUpdateStage }) => void) | null = null; +let registeredCallback: ((data: { stage: YtdlpUpdateStage; verificationFailure?: boolean }) => void) | null = null; beforeEach(() => { registeredCallback = null; @@ -28,9 +28,26 @@ describe('useYtdlpUpdater', () => { it('updates stage (and its label) when the main process reports progress', () => { const { result } = renderUpdater(); - act(() => registeredCallback?.({ stage: 'building' })); - expect(result.current.stage).toBe('building'); - expect(result.current.stageLabel).toBe('Building (this can take a minute)...'); + act(() => registeredCallback?.({ stage: 'installing' })); + expect(result.current.stage).toBe('installing'); + expect(result.current.stageLabel).toBe('Installing...'); + }); + + it('sets verificationFailure from an error-stage broadcast that carries it, and clears it on the next startUpdate', async () => { + (window.electronAPI.startYtdlpUpdate as ReturnType).mockResolvedValue({ success: true, version: '2026.7.5' }); + const { result } = renderUpdater(); + + act(() => registeredCallback?.({ stage: 'error', verificationFailure: true })); + expect(result.current.verificationFailure).toBe(true); + + await act(async () => { await result.current.startUpdate(); }); + expect(result.current.verificationFailure).toBe(false); + }); + + it('does not set verificationFailure for an error-stage broadcast that omits it', () => { + const { result } = renderUpdater(); + act(() => registeredCallback?.({ stage: 'error' })); + expect(result.current.verificationFailure).toBe(false); }); it('checkForUpdate populates version info and toggles checking', async () => { diff --git a/src/ui/hooks/useYtdlpUpdater.tsx b/src/ui/hooks/useYtdlpUpdater.tsx index 4b4d672..b864ce5 100644 --- a/src/ui/hooks/useYtdlpUpdater.tsx +++ b/src/ui/hooks/useYtdlpUpdater.tsx @@ -3,10 +3,8 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from ' export type YtdlpUpdateStage = | 'idle' | 'checking' - | 'fetching-python-runtime' - | 'installing-pyinstaller' - | 'fetching-yt-dlp' - | 'building' + | 'fetching' + | 'installing' | 'verifying' | 'done' | 'error'; @@ -14,15 +12,24 @@ export type YtdlpUpdateStage = const STAGE_LABELS: Record = { idle: '', checking: 'Checking for updates...', - 'fetching-python-runtime': 'Setting up build tools (first time only)...', - 'installing-pyinstaller': 'Setting up build tools (first time only)...', - 'fetching-yt-dlp': 'Fetching latest yt-dlp...', - building: 'Building (this can take a minute)...', + fetching: 'Downloading latest yt-dlp...', + installing: 'Installing...', verifying: 'Verifying...', done: 'Update complete', error: 'Update failed', }; +// Every stage that means "an update is actively in flight" -- shared by +// YtdlpUpdateDialog.tsx (the full-view overlay) and OptionsScreen.tsx (the +// button/chip state), exported once here rather than each duplicating its +// own copy of this set. +export const IN_PROGRESS_STAGES = new Set([ + 'checking', + 'fetching', + 'installing', + 'verifying', +]); + // A single instance of this state lives at the app root (see // YtdlpUpdaterProvider below) so there's exactly one 'ytdlpUpdateProgress' // IPC listener -- the preload bridge's removeYtdlpUpdateProgressListener @@ -38,10 +45,17 @@ function useYtdlpUpdaterState() { const [stage, setStage] = useState('idle'); const [updateError, setUpdateError] = useState(null); + // Set from the 'error'-stage progress broadcast (see main.mjs's + // ytdlp:startUpdate handler) rather than from the rejected startUpdate() + // call below -- a thrown Error's own properties don't survive the IPC + // trip, but this rides along on the same structured-cloned broadcast that + // already carries `stage`. + const [verificationFailure, setVerificationFailure] = useState(false); useEffect(() => { - window.electronAPI.onYtdlpUpdateProgress(({ stage }: { stage: string }) => { + window.electronAPI.onYtdlpUpdateProgress(({ stage, verificationFailure }: { stage: string; verificationFailure?: boolean }) => { setStage(stage as YtdlpUpdateStage); + if (stage === 'error') setVerificationFailure(!!verificationFailure); }); return () => { window.electronAPI.removeYtdlpUpdateProgressListener(); @@ -67,6 +81,7 @@ function useYtdlpUpdaterState() { const startUpdate = async () => { setUpdateError(null); + setVerificationFailure(false); setStage('checking'); try { await window.electronAPI.startYtdlpUpdate(); @@ -94,6 +109,7 @@ function useYtdlpUpdaterState() { stage, stageLabel: STAGE_LABELS[stage], updateError, + verificationFailure, checkForUpdate, startUpdate, quit, diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index 3850864..7b7d24c 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -23,7 +23,7 @@ import { Typography, } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import { useYtdlpUpdater, type YtdlpUpdateStage } from '../hooks/useYtdlpUpdater'; +import { useYtdlpUpdater, IN_PROGRESS_STAGES } from '../hooks/useYtdlpUpdater'; import { useThemeMode } from '../hooks/useThemeMode.tsx'; import { POPULAR_CONVERT_FORMATS, SUGGESTED_EXTRA_CONVERT_FORMATS } from '../../utils/ffmpegFormats.ts'; import { MAX_SIMULTANEOUS_DOWNLOADS_CEILING } from '../../utils/constants.ts'; @@ -51,15 +51,6 @@ const COOKIE_BROWSER_LABELS: Record = { // since main-process and renderer never cross-import in this codebase. const MAX_SIMULTANEOUS_DOWNLOADS_OPTIONS = Array.from({ length: MAX_SIMULTANEOUS_DOWNLOADS_CEILING }, (_, i) => i + 1); -const IN_PROGRESS_STAGES = new Set([ - 'checking', - 'fetching-python-runtime', - 'installing-pyinstaller', - 'fetching-yt-dlp', - 'building', - 'verifying', -]); - // Divider styles for the 2-column option grids below -- a real border keeps // each item visually distinct instead of relying on spacing alone. An item // beside another needs a left border on sm+ (column neighbor) but a top From e7c24a143c45973a459e913bab525dc1c39bebc9 Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 2 Sep 2026 12:25:20 -0700 Subject: [PATCH 2/5] chore: clean up temporary tamper simulation function --- src/electron/ytdlpRelease.mjs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/electron/ytdlpRelease.mjs b/src/electron/ytdlpRelease.mjs index eafd3ae..4c41b5a 100644 --- a/src/electron/ytdlpRelease.mjs +++ b/src/electron/ytdlpRelease.mjs @@ -453,18 +453,6 @@ export async function fetchAndVerifyRelease({ release, assetName, workDir, publi onLog?.(`[ytdlp-release] downloading ${assetName} (${release.tag})`); await downloadFile(assetDownloadUrl(release.tag, assetName), assetPath); - // TEMPORARY manual test hook -- flips one byte of the just-downloaded - // asset so the checksum step below is exercised against real tampered - // data, proving the fail-closed path actually rejects and refuses to - // install rather than just being untested code. Inert unless the env var - // is set. Revert this block once the manual test is done. - if (process.env.SLOTH_DEBUG_TAMPER_YTDLP) { - const tampered = fs.readFileSync(assetPath); - tampered[0] ^= 0xff; - fs.writeFileSync(assetPath, tampered); - onLog?.('[ytdlp-release] TEST: tampered with downloaded asset on purpose'); - } - onLog?.('[ytdlp-release] downloading SHA2-256SUMS and SHA2-256SUMS.sig'); const [sumsText, sigBase64OrBinary] = await Promise.all([ downloadText(assetDownloadUrl(release.tag, 'SHA2-256SUMS')), From ee4cddd6b8d8d77a2eb5adb5d4f00519c9d78b0c Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 2 Sep 2026 12:55:34 -0700 Subject: [PATCH 3/5] feat(app): remove old approach and clean up now unused python runtime --- CONTRIBUTING.md | 15 +- THIRD-PARTY-NOTICES.md | 7 +- docs/PROJECT_STRUCTURE.md | 57 +++--- docs/RELEASING.md | 21 +- package.json | 12 +- scripts/build-ytdlp-bin.mjs | 97 --------- scripts/clean-venv.mjs | 17 -- scripts/fetch-ytdlp-bin.mjs | 5 +- src/electron/updater.mjs | 325 +++--------------------------- src/electron/updater.test.mjs | 71 +------ src/python/requirements-build.txt | 11 - src/python/ytdlp_entrypoint.py | 10 - 12 files changed, 89 insertions(+), 559 deletions(-) delete mode 100644 scripts/build-ytdlp-bin.mjs delete mode 100644 scripts/clean-venv.mjs delete mode 100644 src/python/requirements-build.txt delete mode 100644 src/python/ytdlp_entrypoint.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e1862c..2421e0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,6 @@ actually contains. ## Prerequisites - **Node.js 24** (matches what CI builds with — see `.github/workflows/build.yml`) -- **Python 3.11**, with `venv` available — used to freeze `yt-dlp` into a - standalone binary as part of the build (see [What the build scripts - do](#what-the-build-scripts-do) below) - `npm` ## Setup @@ -80,16 +77,12 @@ The full dependency chain, from `package.json`'s `scripts`: | `dev:vite` | Isolated visual/theme/design work only — not connected to the real app (see above). | | `build:renderer` | Compiles the React UI with Vite into `dist/renderer/`. | | `build:copy:electron` | Copies `src/electron/` (plain JS, no compile step) into `dist/electron/`. | -| `build:ytdlp:bin` | The slow one: creates a local Python venv, installs the exact `yt-dlp` version pinned in `src/python/requirements-build.txt`, and freezes `src/python/ytdlp_entrypoint.py` into a standalone native executable via PyInstaller. This is a real build from source, not a downloaded prebuilt binary — it's also what the app's own in-app "update yt-dlp" feature re-runs later on a user's machine. | +| `build:ytdlp:bin` | Downloads yt-dlp's own official prebuilt release binary and GPG-verifies it against yt-dlp's published signature and checksums before unpacking it into `dist/ytdlp-bin/` — not a build from source. This is also what the app's own in-app "update yt-dlp" feature re-runs later on a user's machine. | | `build:copy:ffmpeg` | Copies the `ffmpeg`/`ffprobe` binaries already fetched by the `ffmpeg-static`/`ffprobe-static` npm packages into `dist/ffmpeg/`. | -| `clean:venv` | Deletes the local Python venv `build:ytdlp:bin` creates, so the next build starts from a clean environment instead of a possibly-stale one. | | `build:electron` | Runs `build:copy:electron` → `build:ytdlp:bin` → `build:copy:ffmpeg`, in order. | -| `build:all` | `build:renderer` + `build:electron` — everything needed to run the app locally via `dev`. | -| `build:all:deep` | `clean:venv` first, then `build:all` — a genuinely from-scratch build. This is what CI/`dist` uses; you shouldn't normally need it locally unless something in the venv is stuck. | +| `build:all` | `build:renderer` + `build:electron` — everything needed to run the app locally via `dev`, and also what CI/`dist` uses. | | `electron-build` | Runs `electron-builder` (packages `dist/` into a real installer for your current OS) without publishing anywhere. | -| `dist` | `build:all:deep` + `electron-build` — produces an actual installer in `dist/`, the same thing a release build does. See `docs/RELEASING.md` for how that connects to an actual GitHub release. | +| `dist` | `build:all` + `electron-build` — produces an actual installer in `dist/`, the same thing a release build does. See `docs/RELEASING.md` for how that connects to an actual GitHub release. | | `prepare` | Runs `husky` to install the pre-commit hook. `npm` runs this automatically after `npm install`/`npm ci` — you shouldn't need to run it yourself. | -If you only need to run the app locally, `build:all` is what you want — the -heavier `build:all:deep`/`dist` scripts exist for producing a real -installer, not for day-to-day development. +If you only need to run the app locally, `build:all` is what you want. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 6f04929..847a036 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -14,9 +14,10 @@ redistributed here. unrestricted; no attribution is legally required, and this notice is included as a courtesy. - **Project:** -- **How it's bundled:** built from source at release time (not the official - prebuilt binary) via PyInstaller, from the exact version pinned in - `src/python/requirements-build.txt`. +- **How it's bundled:** yt-dlp's own official prebuilt release binary, + fetched and GPG-verified against yt-dlp's published signature at build + time (see `scripts/fetch-ytdlp-bin.mjs`), from the exact release tag + pinned there. ## FFmpeg / FFprobe diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index ac90fd7..bf67ac6 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -22,7 +22,6 @@ see [ARCHITECTURE.md](ARCHITECTURE.md). |---|---| | `src/ui/` | The renderer — the React interface. | | `src/electron/` | The Electron main process — all privileged logic (filesystem, subprocesses, IPC). | -| `src/python/` | The `yt-dlp` entrypoint script and pinned Python requirements used to build the bundled `yt-dlp` binary. | | `src/types/` | Shared TypeScript type declarations for the renderer, notably the full type contract for the preload bridge. | | `src/utils/` | Small renderer-side helper modules with no Electron/Node dependency (URL parsing, formatting, a debounce hook). | | `scripts/` | Node scripts that assemble the pieces needed for a working build/install (see [below](#bundled-low-level-dependencies-and-the-build-pipeline)). | @@ -51,7 +50,7 @@ subprocess access at all — its only way to do anything real is through ## The Electron main process (`src/electron`) -This is where essentially all of the app's actual logic lives. Five files: +This is where essentially all of the app's actual logic lives. Six files: ### `main.mjs` @@ -61,7 +60,7 @@ external tools (`yt-dlp`, `ffmpeg`, `ffprobe`). It's easiest to think of it as several loosely-grouped areas rather than one thing: - **Startup/runtime plumbing** — resolving where the bundled `yt-dlp`/`ffmpeg`/ - `ffprobe`/`deno` binaries actually live (this differs between a local dev run + `ffprobe` binaries actually live (this differs between a local dev run and a packaged install — see [below](#bundled-low-level-dependencies-and-the-build-pipeline)), starting a small local HTTP server the browser window loads its UI from instead of a `file://` load, and registering the custom `app-video://` protocol used to @@ -117,13 +116,23 @@ pure filesystem logic, called *by* `main.mjs`'s handlers. ### `updater.mjs` The self-updater for the bundled `yt-dlp` binary, kept separate from `main.mjs` -because it's a genuinely distinct pipeline: checking the latest available -`yt-dlp` version, fetching a portable Python runtime if one isn't already staged, -installing PyInstaller into it, re-freezing `yt-dlp` from source with that -runtime, sanity-checking the result actually runs, and atomically swapping it -into place. This is effectively a smaller, on-device rerun of the same freeze -process `scripts/build-ytdlp-bin.mjs` does at build time (see below) — the two -are intentionally kept in sync. +because it's a genuinely distinct pipeline: resolving the latest release, +fetching and verifying it (via `ytdlpRelease.mjs`, below), sanity-checking the +result actually runs, and atomically swapping it into place. This is +effectively a smaller, on-device rerun of the same fetch process +`scripts/fetch-ytdlp-bin.mjs` does at build time (see below) — the two share +`ytdlpRelease.mjs` directly rather than being kept manually in sync. + +### `ytdlpRelease.mjs` + +The actual fetch/verify logic `updater.mjs` and `scripts/fetch-ytdlp-bin.mjs` +both call into: resolving a release from GitHub, downloading the right +platform asset, checking its SHA-256 against yt-dlp's published +`SHA2-256SUMS`, verifying that file's own GPG signature against a vendored +copy of yt-dlp's public key, and unzipping the result. Deliberately zero third-party dependencies — the packaged app ships with no +`node_modules` at all — so this hand-rolls just enough of a ZIP reader and an +OpenPGP-signature parser to do the job, delegating all actual cryptography to +Node's built-in `crypto` module. ### `utils/constants.mjs` @@ -176,26 +185,24 @@ A few things worth internalizing from this: ## Bundled low-level dependencies and the build pipeline -The app depends on three external tools it never assumes the user already has -installed: `yt-dlp`, `ffmpeg`/`ffprobe`, and `deno` (a small JS runtime `yt-dlp` -needs to solve certain sites' anti-bot challenges). All three are bundled inside -the packaged app rather than downloaded at runtime, assembled by scripts run as -part of the build: +The app depends on two external tools it never assumes the user already has +installed: `yt-dlp` and `ffmpeg`/`ffprobe`. The JS runtime `yt-dlp` needs to +solve certain sites' anti-bot challenges is Electron's own bundled Node +runtime (`--js-runtimes node:`, see `main.mjs`'s +`jsRuntimeArgs`) rather than a separately bundled binary. Both remaining +tools are bundled inside the packaged app rather than downloaded at runtime, +assembled by scripts run as part of the build: | Script | What it produces | |---|---| -| `scripts/build-ytdlp-bin.mjs` | Builds `yt-dlp` itself, from source — creates a local Python virtual environment, installs the exact `yt-dlp` version pinned in `src/python/requirements-build.txt` (plus its own JS-challenge-solver package and PyInstaller), and freezes `src/python/ytdlp_entrypoint.py` into a standalone native executable. This is a real local build step, not a download of a prebuilt binary — it's what lets the app avoid depending on a system Python install, and it's the same freeze process `updater.mjs` reruns later for in-app self-updates. | +| `scripts/fetch-ytdlp-bin.mjs` | Downloads yt-dlp's own official prebuilt release binary and GPG-verifies it (via `ytdlpRelease.mjs`, see above) before unpacking it into `dist/ytdlp-bin/` — not a build from source. This is the same fetch/verify path `updater.mjs` reruns later for in-app self-updates. | | `scripts/copy-ffmpeg.mjs` | Copies the `ffmpeg`/`ffprobe` binaries already fetched by their own npm packages into the build output, preserving the right executable name/extension per platform. | -| `scripts/copy-deno.mjs` | Same idea, for the `deno` binary. | | `scripts/copy-electron.mjs` | Copies the main-process source (`src/electron/`) into the build output as-is — it's plain JS, so this is a copy, not a compile. | -| `scripts/clean-venv.mjs` | Deletes the local Python virtual environment `build-ytdlp-bin.mjs` creates, so a full rebuild starts from a clean environment rather than reusing a possibly stale or (on Windows) account-locked one. | These are chained together by `package.json`'s own `build:*` npm scripts (renderer -build, then each of the copy/build steps above, in order), and the final packaging -step (`electron-builder`) bundles the resulting `dist/ytdlp-bin/`, `dist/ffmpeg/`, -and `dist/deno/` folders into the installed app as extra, read-only resources — -alongside a copy of the Python entrypoint script and requirements file (used -later, entirely on the user's own machine, if they ever trigger a self-update). +build, then each of the fetch/copy steps above, in order), and the final packaging +step (`electron-builder`) bundles the resulting `dist/ytdlp-bin/` and `dist/ffmpeg/` +folders into the installed app as extra, read-only resources. At runtime, `main.mjs` resolves the real path to each of these differently depending on whether it's running from a local dev checkout or an installed, @@ -215,6 +222,6 @@ elevated permissions. | Adding a brand-new capability the UI needs from the main process | `main.mjs` (add the handler) → `preload.cjs` (expose it) → `src/types/electron-api.d.ts` (type it) | | Changing a screen or adding a new one | `src/ui/screens/` | | Changing shared, stateful UI logic (the bulk queue, download progress, search) | `src/ui/hooks/` | -| Touching the self-update flow | `updater.mjs` | -| Changing how `yt-dlp`/`ffmpeg`/`deno` get bundled at build time | the relevant `scripts/*.mjs` file | +| Touching the self-update flow | `updater.mjs`, or `ytdlpRelease.mjs` for the fetch/verify logic itself | +| Changing how `yt-dlp`/`ffmpeg` get bundled at build time | the relevant `scripts/*.mjs` file | | Adjusting persisted settings (a new Options-tab toggle, etc.) | the settings handlers in `main.mjs`, plus wherever it's read in `src/ui/screens/OptionsScreen.tsx` | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fb9c970..903c3d8 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -165,8 +165,8 @@ Not currently supported as a separate path — the workflow either releases push and release creation both fail with a permissions error. - **Settings → Actions → General → Actions permissions** should allow the standard actions this workflow uses (`actions/checkout`, - `actions/setup-node`, `actions/setup-python`) to run — the default "Allow - all actions and reusable workflows" is fine. + `actions/setup-node`) to run — the default "Allow all actions and reusable + workflows" is fine. - No code-signing secrets exist or are required today — every installer ships unsigned (`mac.identity: null`, no Windows certificate configured). Windows shows a SmartScreen "Unknown Publisher" warning and macOS shows a Gatekeeper @@ -180,9 +180,10 @@ Not currently supported as a separate path — the workflow either releases a full build on every merge (without publishing) specifically to catch build breakage immediately. That was deliberately removed to guarantee a no-version-bump merge costs nothing — the tradeoff is that a merge which - breaks the *packaging* build (electron-builder, the PyInstaller freeze, - etc.) silently doesn't get caught until the next real version bump tries - to release. `.github/workflows/ci.yml` covers a different, cheaper gap — + breaks the *packaging* build (electron-builder, fetching/verifying the + `yt-dlp` release binary, etc.) silently doesn't get caught until the next + real version bump tries to release. `.github/workflows/ci.yml` covers a + different, cheaper gap — lint + the Vitest suite on every pull request — which catches broken code well before merge, but it never runs `npm run dist`, so a packaging-only failure can still slip through. @@ -190,10 +191,12 @@ Not currently supported as a separate path — the workflow either releases re-extracts the entire app on every launch, not just once at install time. Not a priority today. - **Apple Silicon vs. Intel mac builds are two separate native jobs**, not - one universal2 binary — a true universal binary needs more work than this - (the PyInstaller-frozen `yt-dlp` binary can't cross-compile between - architectures, so producing one means running that build twice and merging - the results with `lipo`). + one universal2 binary. `yt-dlp`'s own official macOS release is already a + universal2 binary, so that's no longer what's blocking this — the + remaining work is auditing `ffmpeg-static`/`ffprobe-static`'s per-arch + bundling and switching electron-builder over to its own universal-build + mode. Tracked as a must-fix item before this project stops using separate + mac jobs. - **Linux support is new** — the `AppImage` target and the `ubuntu-latest` build job were both added the same day as this document; treat it as less battle-tested than the Windows/macOS paths until it's been through a diff --git a/package.json b/package.json index dc1e58c..0a18365 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,6 @@ "filter": [ "**/*" ] - }, - { - "from": "src/python/", - "to": "python-src", - "filter": [ - "ytdlp_entrypoint.py", - "requirements-build.txt" - ] } ], "directories": { @@ -67,15 +59,13 @@ "lint": "eslint .", "preview": "vite preview", "electron-build": "electron-builder --publish never", - "dist": "npm run build:all:deep && npm run electron-build", + "dist": "npm run build:all && npm run electron-build", "build:renderer": "vite build", "build:copy:electron": "node scripts/copy-electron.mjs", "build:ytdlp:bin": "node scripts/fetch-ytdlp-bin.mjs", "build:copy:ffmpeg": "node scripts/copy-ffmpeg.mjs", "build:electron": "npm run build:copy:electron && npm run build:ytdlp:bin && npm run build:copy:ffmpeg", "build:all": "npm run build:renderer && npm run build:electron", - "clean:venv": "node scripts/clean-venv.mjs", - "build:all:deep": "npm run clean:venv && npm run build:all", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/scripts/build-ytdlp-bin.mjs b/scripts/build-ytdlp-bin.mjs deleted file mode 100644 index f5d1de3..0000000 --- a/scripts/build-ytdlp-bin.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { spawnSync } from 'child_process'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..'); -const venvDir = path.join(rootDir, '.venv-build'); -const rawDist = path.join(rootDir, 'dist', 'ytdlp-bin-raw'); -const finalDist = path.join(rootDir, 'dist', 'ytdlp-bin'); -const pyinstallerWorkDir = path.join(rootDir, 'build', 'pyinstaller'); - -const isWindows = process.platform === 'win32'; - -function run(command, args) { - const result = spawnSync(command, args, { stdio: 'inherit' }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error(`${command} ${args.join(' ')} exited with code ${result.status}`); - } -} - -function commandExists(command) { - return spawnSync(isWindows ? 'where' : 'which', [command]).status === 0; -} - -// fs.cpSync's `dereference` option only resolves a symlink passed as the copy root, not -// symlinks encountered while walking a directory tree -- so a plain recursive cpSync leaves -// PyInstaller's macOS _internal/Python symlink (an absolute path back into this build's -// rawDist) dangling once rawDist is removed below. Walk and dereference manually instead. -function copyDereferenced(src, dest) { - const stat = fs.statSync(src); // statSync (not lstatSync) already follows symlinks. - if (stat.isDirectory()) { - fs.mkdirSync(dest, { recursive: true }); - for (const entry of fs.readdirSync(src)) { - copyDereferenced(path.join(src, entry), path.join(dest, entry)); - } - } else { - fs.copyFileSync(src, dest); - } -} - -// venv layout differs by platform: POSIX uses bin/, Windows uses Scripts/. -const venvBinDir = path.join(venvDir, isWindows ? 'Scripts' : 'bin'); -const venvPython = path.join(venvBinDir, isWindows ? 'python.exe' : 'python'); -const venvPyinstaller = path.join(venvBinDir, isWindows ? 'pyinstaller.exe' : 'pyinstaller'); - -if (!fs.existsSync(venvDir)) { - const systemPython = !isWindows && commandExists('python3') ? 'python3' : 'python'; - run(systemPython, ['-m', 'venv', venvDir]); -} - -run(venvPython, ['-m', 'pip', 'install', '--quiet', '--upgrade', 'pip']); -run(venvPython, ['-m', 'pip', 'install', '--quiet', '-r', path.join(rootDir, 'src', 'python', 'requirements-build.txt')]); - -fs.rmSync(rawDist, { recursive: true, force: true }); -fs.rmSync(finalDist, { recursive: true, force: true }); -fs.rmSync(pyinstallerWorkDir, { recursive: true, force: true }); - -run(venvPyinstaller, [ - '--onedir', - '--name', 'yt-dlp', - '--distpath', rawDist, - '--workpath', pyinstallerWorkDir, - '--specpath', pyinstallerWorkDir, - '--collect-all', 'yt_dlp', - // Bundles yt-dlp's JS-challenge solver scripts so solving YouTube's nsig - // challenge doesn't require the app to fetch them from GitHub at runtime - // (yt-dlp's own --remote-components ejs:github fallback). - // requirements-build.txt installs yt-dlp[default] specifically to pull - // this package in. - '--collect-all', 'yt_dlp_ejs', - // Browser-TLS-fingerprint impersonation (Dailymotion, Instagram, TikTok) - // -- a compiled C extension (bundled libcurl), so it needs the same - // --collect-all treatment as the pure-Python packages above to actually - // get its native bits into the frozen build. requirements-build.txt - // pins the version range yt-dlp's own compat shim accepts. - '--collect-all', 'curl_cffi', - '--noconfirm', - path.join(rootDir, 'src', 'python', 'ytdlp_entrypoint.py'), -]); - -// PyInstaller names the executable yt-dlp.exe on Windows, yt-dlp elsewhere. -const binName = fs.existsSync(path.join(rawDist, 'yt-dlp', 'yt-dlp.exe')) ? 'yt-dlp.exe' : 'yt-dlp'; - -fs.mkdirSync(finalDist, { recursive: true }); -copyDereferenced(path.join(rawDist, 'yt-dlp'), finalDist); -try { - fs.chmodSync(path.join(finalDist, binName), 0o755); -} catch { - // chmod is a no-op on Windows; ignore. -} -fs.rmSync(rawDist, { recursive: true, force: true }); - -console.log(`Built yt-dlp onedir binary -> ${path.join(finalDist, binName)}`); diff --git a/scripts/clean-venv.mjs b/scripts/clean-venv.mjs deleted file mode 100644 index 3da5d6c..0000000 --- a/scripts/clean-venv.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -// .venv-build's own python/pip binaries record an absolute path back to -// whichever account's Python created them (Windows venvs are launcher stubs -// that redirect to that recorded path, not self-contained copies) -- reusing -// it from a *different* Windows account than the one that built it fails -// with "Access is denied" (that account's profile folder is off-limits). -// This deletes the stale venv so build-ytdlp-bin.mjs recreates it fresh -// under whichever account is currently running the build. -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..'); -const venvDir = path.join(rootDir, '.venv-build'); - -fs.rmSync(venvDir, { recursive: true, force: true }); -console.log(`Removed ${venvDir}`); diff --git a/scripts/fetch-ytdlp-bin.mjs b/scripts/fetch-ytdlp-bin.mjs index 37b96e5..d85c894 100644 --- a/scripts/fetch-ytdlp-bin.mjs +++ b/scripts/fetch-ytdlp-bin.mjs @@ -10,9 +10,8 @@ const finalDist = path.join(rootDir, 'dist', 'ytdlp-bin'); // Pinned so every SlothArchiver release bundles a known, reproducible // yt-dlp version rather than "whatever GitHub's latest release happened to -// be at build time" -- the direct replacement for -// requirements-build.txt's own version pin. Bump this by hand in the same -// commit as any other dependency-version bump. +// be at build time." Bump this by hand in the same commit as any other +// dependency-version bump. const PINNED_YTDLP_TAG = '2026.08.19'; fs.rmSync(workDir, { recursive: true, force: true }); diff --git a/src/electron/updater.mjs b/src/electron/updater.mjs index 06bd794..5039a94 100644 --- a/src/electron/updater.mjs +++ b/src/electron/updater.mjs @@ -1,75 +1,22 @@ import fs from 'fs'; import path from 'path'; -import https from 'https'; import { spawn } from 'child_process'; import { resolveLatestRelease, mapPlatformToAssetName, detectMusl, fetchAndVerifyRelease } from './ytdlpRelease.mjs'; -// Every command below talks to the network (PyPI, GitHub); a stalled -// connection (a dropped packet with no RST, a proxy/AV product holding the -// socket, etc.) would otherwise leave the spawn() promise pending forever -- -// no resolve, no reject, nothing logged, and the update overlay has no -// cancel button (yt-dlp is a required dependency). DEFAULT_TIMEOUT_MS bounds -// every step so a genuine hang surfaces as a real, logged, retryable error -// instead of an indefinite silent spinner. Generous on purpose -- a slow -// connection legitimately downloading PyInstaller/yt-dlp/curl_cffi for the -// first time can take a while; this is only meant to catch "actually stuck," -// not "slower than usual." +// A stalled --version probe (a wedged binary, a machine under heavy load) +// would otherwise leave this promise pending forever -- no resolve, no +// reject, nothing logged, and the update overlay has no cancel button +// (yt-dlp is a required dependency). This bounds it so a genuine hang +// surfaces as a real, logged, retryable error instead of an indefinite +// silent spinner. const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; -// Electron's main process is single-threaded -- a spawnSync call here would -// block ALL main-process work (every IPC handler, not just this one) for the -// full duration of each step, several seconds at a time for pip/PyInstaller. -// Everything below uses async spawn instead so the app (and the renderer's -// live progress display) stays responsive while an update runs. -// -// onLog, when given, is main.mjs's own log() (writes to userData/main.log, -// already viewable via Options -> "Open error log") -- passed through rather -// than imported directly so this module stays testable without an Electron -// `app` instance. Logs the command about to run and its outcome (including -// stderr on failure/timeout) so a future hang or failure has an actual trail -// to look at instead of nothing. -function run(command, args, { onLog, timeoutMs = DEFAULT_TIMEOUT_MS, ...spawnOptions } = {}) { - onLog?.(`[ytdlp-update] running: ${command} ${args.join(' ')}`); - return new Promise((resolve, reject) => { - const child = spawn(command, args, spawnOptions); - let stdout = ''; - let stderr = ''; - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - child.kill(); - const message = `${command} ${args.join(' ')} timed out after ${Math.round(timeoutMs / 1000)}s with no response`; - onLog?.(`[ytdlp-update] TIMED OUT: ${message}`); - reject(new Error(message)); - }, timeoutMs); - child.stdout?.on('data', (chunk) => { stdout += chunk; }); - child.stderr?.on('data', (chunk) => { stderr += chunk; }); - child.on('error', (err) => { - clearTimeout(timer); - if (timedOut) return; - onLog?.(`[ytdlp-update] failed to start: ${command} -- ${err.message}`); - reject(err); - }); - child.on('close', (code) => { - clearTimeout(timer); - if (timedOut) return; - if (code !== 0) { - onLog?.(`[ytdlp-update] exited with code ${code}: ${command} ${args.join(' ')}\n${stderr}`); - reject(new Error(`${command} ${args.join(' ')} exited with code ${code}: ${stderr}`)); - } else { - onLog?.(`[ytdlp-update] done: ${command} ${args.join(' ')}`); - resolve({ stdout, stderr }); - } - }); - }); -} - -// Same shape as run(), but resolves with success/code instead of rejecting on -// a non-zero exit -- for probes where "it failed" is an expected, handled -// outcome. Same timeout treatment as run() -- a probe hanging forever (e.g. -// `pythonExe -m PyInstaller --version` on a wedged interpreter) is exactly as -// silent a failure mode as a run() call hanging, just without the courtesy -// of a non-zero exit code to reject on. +// Resolves with success/code instead of rejecting on a non-zero exit -- for +// probes where "it failed" is an expected, handled outcome (checking +// whether a binary is actually runnable). onLog, when given, is main.mjs's +// own log() (writes to userData/main.log, already viewable via Options -> +// "Open error log") -- passed through rather than imported directly so this +// module stays testable without an Electron `app` instance. function probe(command, args, { onLog, timeoutMs = DEFAULT_TIMEOUT_MS, ...spawnOptions } = {}) { onLog?.(`[ytdlp-update] probing: ${command} ${args.join(' ')}`); return new Promise((resolve) => { @@ -98,83 +45,6 @@ function probe(command, args, { onLog, timeoutMs = DEFAULT_TIMEOUT_MS, ...spawnO }); } -// Kept as a standalone constant rather than parsed out of -// src/python/requirements-build.txt: that file pins yt-dlp to an exact -// version for reproducible dev/CI builds, but this module's whole point is -// installing the *latest* yt-dlp -- reusing it as-is would re-pin every -// update to the same stale version. Keep roughly in sync with -// requirements-build.txt's own PyInstaller constraint. -const PYINSTALLER_CONSTRAINT = 'pyinstaller>=6.10,<7'; -const PYTHON_RUNTIME_MINOR = '3.11'; -const PYTHON_BUILD_STANDALONE_REPO = 'astral-sh/python-build-standalone'; - -// Same hang risk as run()/probe() above, same fix: https.get's own `timeout` -// option only ever *emits* a 'timeout' event, it doesn't abort anything on -// its own -- req.destroy() is what actually ends a stalled connection and -// routes it into the existing 'error' handler below. -function fetchJson(url, { onLog, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { - return new Promise((resolve, reject) => { - const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - fetchJson(res.headers.location, { onLog, timeoutMs }).then(resolve, reject); - return; - } - if (res.statusCode !== 200) { - reject(new Error(`Request to ${url} failed with status ${res.statusCode}`)); - res.resume(); - return; - } - let data = ''; - res.on('data', (chunk) => { data += chunk; }); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch { - reject(new Error(`Failed to parse JSON from ${url}`)); - } - }); - }).on('error', reject); - req.setTimeout(timeoutMs, () => { - onLog?.(`[ytdlp-update] TIMED OUT fetching ${url} after ${Math.round(timeoutMs / 1000)}s`); - req.destroy(new Error(`Request to ${url} timed out after ${Math.round(timeoutMs / 1000)}s`)); - }); - }); -} - -function downloadFile(url, destPath, { onLog, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { - return new Promise((resolve, reject) => { - const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - downloadFile(res.headers.location, destPath, { onLog, timeoutMs }).then(resolve, reject); - return; - } - if (res.statusCode !== 200) { - reject(new Error(`Download from ${url} failed with status ${res.statusCode}`)); - res.resume(); - return; - } - const fileStream = fs.createWriteStream(destPath); - res.pipe(fileStream); - fileStream.on('finish', () => fileStream.close(() => resolve())); - fileStream.on('error', reject); - }).on('error', reject); - // Only guards against a stalled *connection* (no bytes at all for - // timeoutMs) -- a slow-but-steady download of a real multi-MB archive - // keeps resetting this timer via the response/data activity Node - // already tracks for `timeout`, so it won't fire on genuinely slow - // (not stuck) downloads. - req.setTimeout(timeoutMs, () => { - onLog?.(`[ytdlp-update] TIMED OUT downloading ${url} after ${Math.round(timeoutMs / 1000)}s of inactivity`); - req.destroy(new Error(`Download from ${url} timed out after ${Math.round(timeoutMs / 1000)}s of inactivity`)); - }); - }); -} - -export async function getLatestYtdlpVersionFromPyPI() { - const data = await fetchJson('https://pypi.org/pypi/yt-dlp/json'); - return data.info.version; -} - // PyPI's version string ("2026.7.4") and yt-dlp's own --version output // ("2026.07.04", zero-padded) refer to the same release but aren't equal as // raw strings -- normalize each segment to an integer before comparing. @@ -197,112 +67,11 @@ export async function getCurrentYtdlpVersion(ytdlpPath, { onLog } = {}) { return result.stdout.trim(); } -// python-build-standalone asset names look like: -// cpython-3.11.15+20260728-x86_64-apple-darwin-install_only.tar.gz -// cpython-3.11.15+20260728-aarch64-pc-windows-msvc-install_only.tar.gz -// Matched by regex (not an exact name) since the patch version and release -// tag both float independently of the minor version/platform/arch we care about. -function findPythonRuntimeAsset(releaseJson) { - const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; - const platformPart = process.platform === 'win32' ? 'pc-windows-msvc' : 'apple-darwin'; - const pattern = new RegExp(`^cpython-${PYTHON_RUNTIME_MINOR.replace('.', '\\.')}\\.\\d+\\+\\d+-${arch}-${platformPart}-install_only\\.tar\\.gz$`); - const asset = (releaseJson.assets || []).find((a) => pattern.test(a.name)); - if (!asset) { - throw new Error(`No matching python-build-standalone asset found for ${process.platform}/${process.arch}`); - } - return asset; -} - -function pythonExePath(runtimeDir) { - return process.platform === 'win32' - ? path.join(runtimeDir, 'python', 'python.exe') - : path.join(runtimeDir, 'python', 'bin', 'python3'); -} - -// Lazy + one-time: only fetched the first time the user actually triggers an -// update, so the base app install size is unaffected (protects the work -// already done to keep the packaged app small). -async function ensurePythonRuntime(runtimeDir, onProgress, onLog) { - const pythonExe = pythonExePath(runtimeDir); - if (fs.existsSync(pythonExe)) { - return pythonExe; - } - - onProgress?.('fetching-python-runtime'); - fs.rmSync(runtimeDir, { recursive: true, force: true }); - fs.mkdirSync(runtimeDir, { recursive: true }); - - const release = await fetchJson(`https://api.github.com/repos/${PYTHON_BUILD_STANDALONE_REPO}/releases/latest`, { onLog }); - const asset = findPythonRuntimeAsset(release); - const archivePath = path.join(runtimeDir, asset.name); - await downloadFile(asset.browser_download_url, archivePath, { onLog }); - - const extract = await probe('tar', ['-xzf', archivePath, '-C', runtimeDir], { onLog }); - fs.rmSync(archivePath, { force: true }); - if (!extract.ok) { - throw new Error(`Failed to extract python runtime: ${extract.timedOut ? 'timed out' : extract.stderr}`); - } - - if (!fs.existsSync(pythonExe)) { - throw new Error('python-build-standalone archive did not contain the expected python executable'); - } - return pythonExe; -} - -// Pinned + one-time: installed once into the runtime and reused across every -// future update, mirroring how .venv-build already persists across local dev -// builds rather than being recreated on every invocation. -async function ensurePyinstaller(pythonExe, onProgress, onLog) { - const check = await probe(pythonExe, ['-m', 'PyInstaller', '--version'], { onLog }); - if (check.ok) { - return; - } - onProgress?.('installing-pyinstaller'); - await run(pythonExe, ['-m', 'pip', 'install', '--quiet', PYINSTALLER_CONSTRAINT, 'certifi'], { onLog }); -} - -async function rebuildYtdlp({ pythonExe, pythonSrcDir, stagingWorkDir, onProgress, onLog }) { - onProgress?.('fetching-yt-dlp'); - // [default] pulls in yt-dlp-ejs, the JS-challenge solver scripts - // YouTube's nsig challenge needs, mirroring scripts/build-ytdlp-bin.mjs's - // own requirements-build.txt pin so a self-updated binary doesn't - // regress back to the un-bundled state. - await run(pythonExe, ['-m', 'pip', 'install', '--quiet', '--upgrade', 'yt-dlp[default]'], { onLog }); - // Browser-TLS-fingerprint impersonation, same pin as requirements-build.txt - // (yt-dlp's own compat shim hard-rejects anything outside 0.5.10/0.10.x-0.15.x) - // -- required outright by Dailymotion, and used unconditionally in real - // request paths by Instagram/TikTok. Installed (not upgraded) so a - // self-update never silently drifts outside yt-dlp's supported range. - await run(pythonExe, ['-m', 'pip', 'install', '--quiet', 'curl_cffi>=0.10,<0.16'], { onLog }); - - onProgress?.('building'); - const rawDist = path.join(stagingWorkDir, 'raw'); - const pyinstallerWorkDir = path.join(stagingWorkDir, 'work'); - fs.rmSync(rawDist, { recursive: true, force: true }); - fs.rmSync(pyinstallerWorkDir, { recursive: true, force: true }); - - await run(pythonExe, [ - '-m', 'PyInstaller', - '--onedir', - '--name', 'yt-dlp', - '--distpath', rawDist, - '--workpath', pyinstallerWorkDir, - '--specpath', pyinstallerWorkDir, - '--collect-all', 'yt_dlp', - '--collect-all', 'yt_dlp_ejs', - '--collect-all', 'curl_cffi', - '--noconfirm', - path.join(pythonSrcDir, 'ytdlp_entrypoint.py'), - ], { onLog }); - - return path.join(rawDist, 'yt-dlp'); -} - -// Same manual dereferencing copy used by scripts/build-ytdlp-bin.mjs: -// PyInstaller's macOS onedir output uses an absolute symlink for -// _internal/Python pointing back into this build's temp work dir, so a -// naive copy can leave a dangling reference. statSync follows symlinks; -// lstatSync doesn't. +// Dereferencing copy: statSync follows symlinks, lstatSync doesn't. The +// extracted release directory can't just be fs.renameSync'd straight into +// place (it has to become `${liveDir}-staging` first, see verifyAndSwap +// below), and a naive recursive copy would leave a dangling reference if +// the source directory ever contains an absolute symlink. function copyDereferenced(src, dest) { const stat = fs.statSync(src); if (stat.isDirectory()) { @@ -340,59 +109,15 @@ async function verifyAndSwap({ builtDir, liveDir, binaryName, onLog }) { return verify.stdout.trim(); } -// This Python/PyInstaller update path is retired -- no longer called by -// main.mjs's ytdlp:startUpdate handler, which now calls performYtdlpUpdate -// (below, the former performYtdlpUpdateViaGitHub) instead. Kept here, fully -// intact and still independently testable, only until one full shipped -// release cycle has run on the new path with no reported issues -- at which -// point this, ensurePythonRuntime, ensurePyinstaller, rebuildYtdlp, -// findPythonRuntimeAsset, pythonExePath, and the other Python-era helpers -// above all get deleted together. -// -// onLog is optional (main.mjs passes its own log() -> userData/main.log, -// already viewable via Options -> "Open error log") -- every step below logs -// the command it's about to run and how it ended (including a timeout, see -// run()/probe()/fetchJson()/downloadFile() above), so a stuck or failed -// update actually leaves a trail instead of nothing -- an installed app has -// no console to watch otherwise. -export async function performYtdlpUpdateLegacy({ userDataDir, pythonSrcDir, liveYtdlpBinDir, ytdlpBinaryName, isDownloadActive, onProgress, onLog }) { - if (isDownloadActive && isDownloadActive()) { - throw new Error('A download is currently in progress. Finish it before applying a yt-dlp update.'); - } - - onProgress?.('checking'); - onLog?.('[ytdlp-update] starting update'); - const runtimeDir = path.join(userDataDir, 'python-runtime'); - const stagingWorkDir = path.join(userDataDir, 'ytdlp-update-work'); - - const pythonExe = await ensurePythonRuntime(runtimeDir, onProgress, onLog); - await ensurePyinstaller(pythonExe, onProgress, onLog); - const builtDir = await rebuildYtdlp({ pythonExe, pythonSrcDir, stagingWorkDir, onProgress, onLog }); - - onProgress?.('verifying'); - if (isDownloadActive && isDownloadActive()) { - throw new Error('A download started while the update was building. Finish it, then try applying the update again.'); - } - const newVersion = await verifyAndSwap({ builtDir, liveDir: liveYtdlpBinDir, binaryName: ytdlpBinaryName, onLog }); - - fs.rmSync(stagingWorkDir, { recursive: true, force: true }); - onProgress?.('done'); - onLog?.(`[ytdlp-update] update complete -> ${newVersion}`); - return { version: newVersion }; -} - -// Replacement for performYtdlpUpdateLegacy above: fetches and GPG-verifies -// yt-dlp's own official release binary (see ytdlpRelease.mjs) instead of -// rebuilding it locally from unpinned pip installs. This is what main.mjs's -// ytdlp:startUpdate handler actually calls now. +// Fetches and GPG-verifies yt-dlp's own official release binary (see +// ytdlpRelease.mjs) and atomically swaps it into liveYtdlpBinDir. This is +// what main.mjs's ytdlp:startUpdate handler calls. // -// isDownloadActive/onProgress/onLog carry the exact same meaning as -// performYtdlpUpdateLegacy's own -- a download in progress still blocks -// starting an update (and still aborts one already in flight if a download -// starts mid-way), and progress/log callbacks still exist so the renderer's -// overlay and userData/main.log stay informative. verifyAndSwap is reused -// completely unchanged: it was always generic over "a directory containing -// a working binary," regardless of how that directory was produced. +// isDownloadActive blocks starting an update while a download is in +// progress (and aborts one already in flight if a download starts +// mid-way) -- yt-dlp is a live subprocess dependency, so swapping its +// binary out from under an active download isn't safe. progress/log +// callbacks keep the renderer's overlay and userData/main.log informative. export async function performYtdlpUpdate({ userDataDir, liveYtdlpBinDir, ytdlpBinaryName, isDownloadActive, onProgress, onLog }) { if (isDownloadActive && isDownloadActive()) { throw new Error('A download is currently in progress. Finish it before applying a yt-dlp update.'); diff --git a/src/electron/updater.test.mjs b/src/electron/updater.test.mjs index a42497d..ec8badf 100644 --- a/src/electron/updater.test.mjs +++ b/src/electron/updater.test.mjs @@ -1,16 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; -// child_process.spawn and https.get are mocked rather than exercised for -// real -- getCurrentYtdlpVersion/getLatestYtdlpVersionFromPyPI are thin -// wrappers around them, and this is the boundary where a fake process/ -// response can stand in cheaply and deterministically. performYtdlpUpdate -// and its unexported helpers (rebuildYtdlp, ensurePythonRuntime, -// ensurePyinstaller, verifyAndSwap, findPythonRuntimeAsset) are a much -// bigger multi-step orchestration over spawn/https/fs together -- covering -// those meaningfully would mean re-implementing most of the module as a -// mock, for low return relative to the size of this pass. Left for a later, -// dedicated integration-style pass rather than force-fit here. +// child_process.spawn is mocked rather than exercised for real -- +// getCurrentYtdlpVersion is a thin wrapper around it, and this is the +// boundary where a fake process can stand in cheaply and deterministically. +// performYtdlpUpdate and its unexported helpers (verifyAndSwap, +// copyDereferenced) are a bigger multi-step orchestration over +// ytdlpRelease.mjs/spawn/fs together -- covering those meaningfully would +// mean re-implementing most of the module as a mock, for low return relative +// to the size of this pass. Left for a later, dedicated integration-style +// pass rather than force-fit here. function makeFakeChildProcess() { const child = new EventEmitter(); child.stdout = new EventEmitter(); @@ -78,55 +77,3 @@ describe('getCurrentYtdlpVersion', () => { await expect(getCurrentYtdlpVersion('/missing/yt-dlp')).rejects.toThrow(/Failed to read current yt-dlp version/); }); }); - -describe('getLatestYtdlpVersionFromPyPI', () => { - it('parses the version out of a successful PyPI response', async () => { - vi.doMock('https', () => ({ - default: { - get: (url, options, callback) => { - const res = new EventEmitter(); - res.statusCode = 200; - res.headers = {}; - res.resume = vi.fn(); - callback(res); - queueMicrotask(() => { - res.emit('data', JSON.stringify({ info: { version: '2026.7.4' } })); - res.emit('end'); - }); - const req = new EventEmitter(); - req.setTimeout = vi.fn(); - return req; - }, - }, - })); - vi.resetModules(); - const { getLatestYtdlpVersionFromPyPI: freshGetLatest } = await import('./updater.mjs'); - - await expect(freshGetLatest()).resolves.toBe('2026.7.4'); - vi.doUnmock('https'); - vi.resetModules(); - }); - - it('rejects on a non-200 status', async () => { - vi.doMock('https', () => ({ - default: { - get: (url, options, callback) => { - const res = new EventEmitter(); - res.statusCode = 500; - res.headers = {}; - res.resume = vi.fn(); - callback(res); - const req = new EventEmitter(); - req.setTimeout = vi.fn(); - return req; - }, - }, - })); - vi.resetModules(); - const { getLatestYtdlpVersionFromPyPI: freshGetLatest } = await import('./updater.mjs'); - - await expect(freshGetLatest()).rejects.toThrow(/failed with status 500/); - vi.doUnmock('https'); - vi.resetModules(); - }); -}); diff --git a/src/python/requirements-build.txt b/src/python/requirements-build.txt deleted file mode 100644 index 1510686..0000000 --- a/src/python/requirements-build.txt +++ /dev/null @@ -1,11 +0,0 @@ -yt-dlp[default]==2026.7.4 -pyinstaller>=6.10,<7 -certifi -# Browser-TLS-fingerprint impersonation -- required outright by some -# extractors (Dailymotion), and used unconditionally in real request paths -# by others this app already routes downloads through (Instagram, TikTok). -# Pinned to yt-dlp's own supported range: its bundled compat shim -# (yt_dlp/networking/_curlcffi.py) hard-rejects anything outside -# 0.5.10/0.10.x-0.15.x -- confirmed live, curl_cffi 0.16.0 raises -# "Only curl_cffi versions 0.5.10 and 0.10.x through 0.15.x are supported". -curl_cffi>=0.10,<0.16 diff --git a/src/python/ytdlp_entrypoint.py b/src/python/ytdlp_entrypoint.py deleted file mode 100644 index 7c95dcc..0000000 --- a/src/python/ytdlp_entrypoint.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import certifi - -os.environ.setdefault('SSL_CERT_FILE', certifi.where()) -os.environ.setdefault('REQUESTS_CA_BUNDLE', certifi.where()) - -from yt_dlp import main - -if __name__ == '__main__': - main() From 0b01e14bd525bd1eb27e88ae0caceb1fee585d86 Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 2 Sep 2026 13:44:26 -0700 Subject: [PATCH 4/5] fix(app): fix error detection when checking for yt-dlp uodates --- src/electron/main.mjs | 22 +++++++++++++--------- src/types/electron-api.d.ts | 2 +- src/ui/hooks/useYtdlpUpdater.test.tsx | 14 ++++++++++++++ src/ui/hooks/useYtdlpUpdater.tsx | 3 ++- src/ui/screens/OptionsScreen.test.tsx | 14 ++++++++++++++ src/ui/screens/OptionsScreen.tsx | 10 +++++----- 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 3f8e62d..f9c9925 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -25,7 +25,7 @@ export { looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetsca const logFile = path.join(app.getPath("userData"), "main.log"); function log(...args) { - const msg = args.map(String).join(" "); + const msg = `${new Date().toISOString()} ${args.map(String).join(" ")}`; fs.appendFileSync(logFile, msg + "\n"); console.log(msg); } @@ -35,10 +35,10 @@ function log(...args) { // from the Options tab (see errorLog:* handlers below) instead of silently // dying or spamming an invisible console. process.on('uncaughtException', (err) => { - log('[uncaughtException]', new Date().toISOString(), err && err.stack ? err.stack : String(err)); + log('[uncaughtException]', err && err.stack ? err.stack : String(err)); }); process.on('unhandledRejection', (reason) => { - log('[unhandledRejection]', new Date().toISOString(), reason && reason.stack ? reason.stack : String(reason)); + log('[unhandledRejection]', reason && reason.stack ? reason.stack : String(reason)); }); const __filename = fileURLToPath(import.meta.url); @@ -1816,15 +1816,19 @@ ipcMain.handle('library:embedMetadata', async (e, { inputPath, metadataTags, thu }); ipcMain.handle('ytdlp:checkForUpdate', async () => { + const current = await getCurrentYtdlpVersion(ytdlpPath); + // GitHub Releases -- the same source performYtdlpUpdate actually fetches // and verifies the binary from below -- rather than PyPI, which was only // ever a proxy for "what version is latest" and could in principle // disagree with the real update source. - const [release, current] = await Promise.all([ - resolveLatestRelease(), - getCurrentYtdlpVersion(ytdlpPath), - ]); - return { current, latest: release.tag, updateAvailable: isNewerVersion(release.tag, current) }; + try { + const release = await resolveLatestRelease(); + return { current, latest: release.tag, updateAvailable: isNewerVersion(release.tag, current) }; + } catch (err) { + log('[ytdlp-check] failed to resolve the latest release (offline?):', err instanceof Error ? err.message : String(err)); + return { current, latest: null, updateAvailable: false }; + } }); ipcMain.handle('ytdlp:startUpdate', async () => { @@ -1893,7 +1897,7 @@ ipcMain.handle('system:openFileExternally', async (e, filepath) => { // can't write to main.log directly -- no fs access under // contextIsolation/sandbox -- so they're forwarded here. ipcMain.handle('errorLog:report', async (e, { message, stack }) => { - log('[rendererError]', new Date().toISOString(), stack || message || 'Unknown renderer error'); + log('[rendererError]', stack || message || 'Unknown renderer error'); }); ipcMain.handle('errorLog:getInfo', async () => { diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index fe85c67..b727210 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -58,7 +58,7 @@ declare global { getDownloadDir: () => Promise<{ downloadDir: string }> setDownloadDir: (dir: string) => Promise<{ success: boolean; downloadDir: string }> checkFileExists: (filePath: string) => Promise - checkForYtdlpUpdate: () => Promise<{ current: string; latest: string; updateAvailable: boolean }> + checkForYtdlpUpdate: () => Promise<{ current: string; latest: string | null; updateAvailable: boolean }> startYtdlpUpdate: () => Promise<{ success: boolean; version: string }> onYtdlpUpdateProgress: (callback: (data: { stage: string }) => void) => void removeYtdlpUpdateProgressListener: () => void diff --git a/src/ui/hooks/useYtdlpUpdater.test.tsx b/src/ui/hooks/useYtdlpUpdater.test.tsx index 5377ef9..17500d6 100644 --- a/src/ui/hooks/useYtdlpUpdater.test.tsx +++ b/src/ui/hooks/useYtdlpUpdater.test.tsx @@ -68,6 +68,20 @@ describe('useYtdlpUpdater', () => { expect(result.current.checkError).toBeNull(); }); + it('checkForUpdate still shows the current version and surfaces an error when the latest release could not be resolved (e.g. offline)', async () => { + (window.electronAPI.checkForYtdlpUpdate as ReturnType).mockResolvedValue({ + current: '2026.7.4', latest: null, updateAvailable: false, + }); + const { result } = renderUpdater(); + + await act(async () => { await result.current.checkForUpdate(); }); + + expect(result.current.currentVersion).toBe('2026.7.4'); + expect(result.current.latestVersion).toBe(''); + expect(result.current.updateAvailable).toBe(false); + expect(result.current.checkError).toBe('Could not check for the latest version.'); + }); + it('checkForUpdate sets checkError and clears checking on failure', async () => { (window.electronAPI.checkForYtdlpUpdate as ReturnType).mockRejectedValue(new Error('network down')); const { result } = renderUpdater(); diff --git a/src/ui/hooks/useYtdlpUpdater.tsx b/src/ui/hooks/useYtdlpUpdater.tsx index b864ce5..8e85d37 100644 --- a/src/ui/hooks/useYtdlpUpdater.tsx +++ b/src/ui/hooks/useYtdlpUpdater.tsx @@ -68,8 +68,9 @@ function useYtdlpUpdaterState() { try { const result = await window.electronAPI.checkForYtdlpUpdate(); setCurrentVersion(result.current); - setLatestVersion(result.latest); + setLatestVersion(result.latest ?? ''); setUpdateAvailable(result.updateAvailable); + setCheckError(result.latest === null ? 'Could not check for the latest version.' : null); return result; } catch (err) { setCheckError(err instanceof Error ? err.message : 'Failed to check for updates.'); diff --git a/src/ui/screens/OptionsScreen.test.tsx b/src/ui/screens/OptionsScreen.test.tsx index 12b43e7..1846d6b 100644 --- a/src/ui/screens/OptionsScreen.test.tsx +++ b/src/ui/screens/OptionsScreen.test.tsx @@ -147,6 +147,20 @@ describe('OptionsScreen', () => { expect(window.electronAPI.startYtdlpUpdate).toHaveBeenCalled(); }); + it('falls back to showing the current version when checking for the latest release fails (e.g. offline)', async () => { + const user = userEvent.setup(); + (window.electronAPI.checkForYtdlpUpdate as ReturnType).mockResolvedValue({ + current: '2026.7.4', latest: null, updateAvailable: false, + }); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'Check for updates' })); + expect(await screen.findByText('Current: 2026.7.4')).toBeInTheDocument(); + expect(screen.queryByText('Version unknown')).not.toBeInTheDocument(); + expect(screen.queryByText('Up to date')).not.toBeInTheDocument(); + expect(screen.getByText('Could not check for the latest version.')).toBeInTheDocument(); + }); + it('the error log button is disabled until an error has actually been logged', async () => { const user = userEvent.setup(); (window.electronAPI.getErrorLogInfo as ReturnType).mockResolvedValue({ exists: true, path: '/log' }); diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index 7b7d24c..21511b2 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -444,8 +444,8 @@ export default function OptionsScreen() { - yt-dlp needs to change whenever YouTube does. Updating rebuilds it locally on this - machine, which can take a minute or two the first time. + yt-dlp needs to change whenever YouTube does. Updating downloads and verifies + yt-dlp's own signed release build -- this only takes a few seconds. setYtdlpInfoOpen(false)} maxWidth="xs" fullWidth> What is yt-dlp? @@ -479,11 +479,11 @@ export default function OptionsScreen() { label={currentVersion ? `Current: ${currentVersion}` : 'Version unknown'} variant="outlined" /> - {!checking && !updateAvailable && !!currentVersion && + {!checking && !updateAvailable && !!currentVersion && !!latestVersion && } + {!checking && checkError && + } - {checkError && - {checkError}} From c85134aab172fdb0830ce562c80b34d4ff607c8d Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 2 Sep 2026 14:22:58 -0700 Subject: [PATCH 5/5] chore: fix documentation to match new approach for yt-dlp updates --- CONTRIBUTING.md | 5 +---- src/electron/ytdlpRelease.mjs | 17 +++++++---------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2421e0c..303a58c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,10 +33,7 @@ npm run dev `build:all` needs to be re-run whenever you change renderer source (`src/ui/`) **or** main-process source (`src/electron/`) — there's no hot -reload here, `dev` just loads whatever was most recently built. `build:all` -is fast for a code-only change; it does *not* re-freeze `yt-dlp` or -re-copy `ffmpeg` unless you explicitly ask it to (see below) — those are -the slow, rarely-needed steps. +reload here, `dev` just loads whatever was most recently built. > `npm run dev:vite` also exists, but it's not a way to preview the real > app — it's an isolated Vite dev server for hot-swapping visual/theme/design diff --git a/src/electron/ytdlpRelease.mjs b/src/electron/ytdlpRelease.mjs index 4c41b5a..52a8cb2 100644 --- a/src/electron/ytdlpRelease.mjs +++ b/src/electron/ytdlpRelease.mjs @@ -167,8 +167,8 @@ export function detectMusl() { // it, resolves yt-dlp's real latest GitHub release once. Callers must call // this exactly once per operation and thread the single result through every // subsequent step (the asset URL, the SHA2-256SUMS URL, the .sig URL) -- -// resolving "latest" more than once per operation is what let the old -// PyPI-vs-pip-install approach drift to two different versions mid-update. +// resolving "latest" more than once per operation risks two steps drifting +// to different versions if a new release lands in between. export async function resolveLatestRelease({ pin } = {}) { if (pin) { return { tag: pin }; @@ -318,11 +318,10 @@ export function verifyDetachedSignature({ data, signature, publicKeyArmored }) { // Scoped to exactly what a GitHub-Releases-hosted, PyInstaller-onedir build // actually produces: single-disk, under 100MB, "stored" (0) or "deflate" (8) // compression only, no encryption. Confirmed directly against the real -// yt-dlp_macos.zip asset that these official builds contain zero symlinks -// (unlike this project's own local PyInstaller onedir output, which needs -// dereferencing) -- so no symlink handling here on purpose; if a future -// yt-dlp release ever changes that, extraction will fail loudly (an -// unhandled entry type) rather than silently mis-copy a symlink as a file. +// yt-dlp_macos.zip asset that these official builds contain zero symlinks -- +// so no symlink handling here on purpose; if a future yt-dlp release ever +// changes that, extraction will fail loudly (an unhandled entry type) +// rather than silently mis-copy a symlink as a file. const EOCD_SIGNATURE = 0x06054b50; const CENTRAL_DIR_SIGNATURE = 0x02014b50; @@ -498,9 +497,7 @@ export async function fetchAndVerifyRelease({ release, assetName, workDir, publi // (e.g. yt-dlp_macos.zip's launcher is literally called `yt-dlp_macos`, // confirmed against the real asset), not the fixed `yt-dlp`/`yt-dlp.exe` // name every spawn call site in this app expects (main.mjs's - // ytdlpBinaryName). Rename it into place here, once, so every caller of - // fetchAndVerifyRelease gets a directory shaped exactly like this - // project's own previous local PyInstaller build already was. + // ytdlpBinaryName). Rename it into place here, once. renameLauncherToCanonicalName(extractDir, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'); return extractDir;