From 8f3d4a08aa931c0636e48312fa094dd7a7bcf471 Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 12:35:24 +0300 Subject: [PATCH 01/24] embed ffmpeg/ffprobe at build via electron-builder extraResources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BinaryManager.ts: replace runtime ffmpeg/ffprobe ladder w/ override→env→bundled probe (~700 LoC removed) - scripts/build/fetch-embedded.sh: BtbN (Win/Linux) + Martin-Riedl (mac) per (platform, arch) - build/beforeBuild.cjs: electron-builder lifecycle hook - electron-builder.json5: per-platform extraResources w/ ${arch} substitution - THIRD_PARTY_NOTICES.txt: GPLv3 ffmpeg + Unlicense yt-dlp + MIT deno - src/main/utils/process.ts + probeBinary: LD_LIBRARY_PATH for Linux shared libs - vitest: tests/__mocks__/electron.ts for app.isPackaged / --- .env.example | 12 +- .github/workflows/installer-smoke.yml | 3 +- .github/workflows/release.yml | 29 +- .gitignore | 11 +- build/beforeBuild.cjs | 18 + bun.lock | 6 +- electron-builder.json5 | 19 + electron.vite.config.ts | 15 +- package.json | 7 +- scripts/build/fetch-embedded.sh | 179 ++++++ scripts/test-binaries/_lib.sh | 79 +++ scripts/test-binaries/smoke-all.sh | 341 +++++++++++ src/main/index.ts | 19 +- src/main/services/BinaryManager.ts | 533 ++++-------------- src/main/services/WarmupService.ts | 9 +- src/main/services/analytics.ts | 82 ++- src/main/stores/SettingsStore.ts | 15 +- src/main/utils/process.ts | 26 +- .../src/components/system/SplashScreen.tsx | 2 +- src/shared/constants.ts | 5 + src/shared/types.ts | 6 +- tests/__mocks__/aptabase-main.ts | 3 - tests/__mocks__/electron.ts | 5 + tests/unit/analytics-allowlist.test.ts | 159 +++++- tests/unit/analytics-crash-dedupe.test.ts | 54 +- tests/unit/binary-manager-analytics.test.ts | 90 +++ tests/unit/binary-manager-platform.test.ts | 41 +- tests/unit/binary-manager.test.ts | 33 ++ tests/unit/warmup-service.test.ts | 19 +- vitest.config.mts | 2 +- 30 files changed, 1257 insertions(+), 565 deletions(-) create mode 100644 build/beforeBuild.cjs create mode 100755 scripts/build/fetch-embedded.sh create mode 100644 scripts/test-binaries/_lib.sh create mode 100755 scripts/test-binaries/smoke-all.sh delete mode 100644 tests/__mocks__/aptabase-main.ts create mode 100644 tests/__mocks__/electron.ts create mode 100644 tests/unit/binary-manager-analytics.test.ts diff --git a/.env.example b/.env.example index 3f066830..c4bf14fa 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,7 @@ -# Aptabase analytics app key. Leave empty to disable telemetry in dev. -# Format: -- (e.g. A-EU-123456789) -# Obtain a key from https://aptabase.com -APTABASE_KEY=A-EU-XXXXXXXXXX +# Analytics — OpenPanel (https://openpanel.dev) +OPENPANEL_CLIENT_ID= +OPENPANEL_CLIENT_SECRET= -# Set to 1 to send analytics during `bun run dev` (events are tagged debug-mode -# by the SDK so they're filterable on the dashboard). Off by default — HMR +# Set to 1 to send analytics during `bun run dev`. Off by default — HMR # reloads would otherwise spam wizard_started / app_started. -# ARROXY_ANALYTICS_DEBUG=1 \ No newline at end of file +ARROXY_ANALYTICS_DEBUG=1 diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml index 2bd02b78..035a47df 100644 --- a/.github/workflows/installer-smoke.yml +++ b/.github/workflows/installer-smoke.yml @@ -47,7 +47,8 @@ jobs: - name: Build Windows artifacts shell: bash env: - APTABASE_KEY: ${{ secrets.APTABASE_KEY }} + OPENPANEL_CLIENT_ID: ${{ secrets.OPENPANEL_CLIENT_ID }} + OPENPANEL_CLIENT_SECRET: ${{ secrets.OPENPANEL_CLIENT_SECRET }} run: | if [[ "${{ github.ref }}" == refs/tags/v* ]]; then bun run dist:win diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c335ebb..21219e5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,9 +30,10 @@ jobs: # (.github/workflows/installer-smoke.yml) so it can be smoke-tested # before publish. mac/linux still build + publish here via dist:release. # - # ffmpeg/ffprobe are downloaded at runtime by BinaryManager (BtbN for - # Win/Linux, evermeet.cx for macOS) — no per-platform npm optional deps - # to patch in here. + # ffmpeg/ffprobe are embedded at build time via electron-builder's + # beforeBuild hook (build/beforeBuild.cjs invokes + # scripts/build/fetch-embedded.sh). yt-dlp + deno remain runtime-fetched + # by BinaryManager. needs: verify-version strategy: matrix: @@ -51,7 +52,8 @@ jobs: - run: bun run ${{ matrix.dist-cmd }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - APTABASE_KEY: ${{ secrets.APTABASE_KEY }} + OPENPANEL_CLIENT_ID: ${{ secrets.OPENPANEL_CLIENT_ID }} + OPENPANEL_CLIENT_SECRET: ${{ secrets.OPENPANEL_CLIENT_SECRET }} finalize: needs: build @@ -105,9 +107,21 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish release - run: gh release edit ${{ github.ref_name }} --draft=false + # Tags w/ a semver pre-release suffix (e.g. v0.4.0-beta.1) un-draft as + # GitHub Pre-release. electron-updater's GitHub provider queries + # /releases/latest, which excludes pre-releases — existing user installs + # do NOT auto-update. Manual install from the release page works for + # internal testing. Stable tags (e.g. v0.4.0) un-draft as full release + # and trigger normal user auto-update. env: GH_TOKEN: ${{ secrets.WINGET_TOKEN }} + REF_NAME: ${{ github.ref_name }} + run: | + EXTRA="" + if [[ "$REF_NAME" == *-* ]]; then + EXTRA="--prerelease" + fi + gh release edit "$REF_NAME" --draft=false $EXTRA build-flatpak: # Build a Flatpak bundle for the GitHub Release only. @@ -173,6 +187,9 @@ jobs: publish-scoop: needs: finalize + # Skip for pre-release tags (e.g. v0.4.0-beta.1) — only stable releases + # bump the public Scoop bucket. + if: ${{ !contains(github.ref_name, '-') }} runs-on: ubuntu-latest steps: - name: Checkout bucket @@ -212,6 +229,8 @@ jobs: publish-homebrew: needs: finalize + # Skip for pre-release tags — only stable releases bump the Homebrew tap. + if: ${{ !contains(github.ref_name, '-') }} runs-on: ubuntu-latest steps: - name: Checkout tap diff --git a/.gitignore b/.gitignore index 01a0ee0c..05201584 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,13 @@ flatpak/flathub.json flatpak/io.github.antonio_orionus.Arroxy.yml .flatpak-builder/ build-dir/ -private \ No newline at end of file +private + +# Smoke harness output (root-only — scripts/test-binaries/ contains +# the harness scripts themselves and must stay tracked) +/test-binaries/ + +# Build-time embedded ffmpeg/ffprobe (scripts/build/fetch-embedded.sh output) +# Use **/ prefix so it matches both /build/embedded/ and any accidental +# copies (e.g. src/build/embedded/ if a tool resolves cwd wrong). +**/build/embedded/ \ No newline at end of file diff --git a/build/beforeBuild.cjs b/build/beforeBuild.cjs new file mode 100644 index 00000000..acff4e96 --- /dev/null +++ b/build/beforeBuild.cjs @@ -0,0 +1,18 @@ +// electron-builder lifecycle hook. Fires once per (platform, arch) the build +// targets. Invokes scripts/build/fetch-embedded.sh to populate +// build/embedded/-/{ffmpeg, ffprobe}[.exe] before the +// extraResources copy step packs them into the artifact. +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +// electron-builder Arch enum: 0=ia32, 1=x64, 2=armv7l, 3=arm64 +const ARCH_NAMES = ['ia32', 'x64', 'armv7l', 'arm64']; + +exports.default = async function beforeBuild(context) { + const platform = context.platform.nodeName; // 'win32' | 'darwin' | 'linux' + const archName = ARCH_NAMES[context.arch] ?? 'x64'; + const cwd = context.appDir ?? process.cwd(); + const script = path.join(cwd, 'scripts', 'build', 'fetch-embedded.sh'); + console.log(`[beforeBuild] fetch ffmpeg/ffprobe for ${platform}-${archName}`); + execFileSync('bash', [script, platform, archName], { stdio: 'inherit', cwd }); +}; diff --git a/bun.lock b/bun.lock index f4427701..94cdaa49 100644 --- a/bun.lock +++ b/bun.lock @@ -4,12 +4,12 @@ "": { "name": "arroxy", "dependencies": { - "@aptabase/electron": "^0.3.1", "@base-ui/react": "^1.4.1", "@fontsource-variable/geist": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/outfit": "^5.2.8", "@fontsource/poppins": "^5.2.7", + "@openpanel/sdk": "^1.3.1", "@tanstack/react-virtual": "^3.13.24", "@testing-library/dom": "^10.4.1", "class-variance-authority": "^0.7.1", @@ -74,8 +74,6 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@aptabase/electron": ["@aptabase/electron@0.3.1", "", { "peerDependencies": { "electron": ">= 3.x" } }, "sha512-FECaGsjuoQu70F+M6V1evdgLP7yaq/sne9fC60AZZ6B9RsCDlxFBnJzdq3+xevHlUx6AB5o9ygUhQ+ONT9EqiA=="], - "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], @@ -340,6 +338,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@openpanel/sdk": ["@openpanel/sdk@1.3.1", "", {}, "sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.128.0", "", { "os": "android", "cpu": "arm" }, "sha512-aca6ZvzmCBUGOANQRiRQRZuRKYI3ENhcit6GisnknOOmcezfQc7xJ4dxlPU7MV7mOvrC7RNR1u3LAD7xyaiCxA=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.128.0", "", { "os": "android", "cpu": "arm64" }, "sha512-BbeDmuohoJ7Rz/it5wnkj69i/OsCPS3Z51nLEzwO/Y6YshtC4JU+15oNwhY8v4LRKRYclRc7ggOikwrsJ/eOEQ=="], diff --git a/electron-builder.json5 b/electron-builder.json5 index 9aaacca7..1852f13e 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -2,6 +2,7 @@ "appId": "com.arroxy.app", "productName": "Arroxy", "afterPack": "./build/afterPack.cjs", + "beforeBuild": "./build/beforeBuild.cjs", "directories": { "output": "dist", "buildResources": "build" @@ -11,9 +12,18 @@ { "from": "build/icon-tray.png", "to": "icon-tray.png" + }, + { + "from": "THIRD_PARTY_NOTICES.txt", + "to": "THIRD_PARTY_NOTICES.txt" } ], "win": { + "extraResources": [ + { "from": "build/embedded/win32-${arch}/ffmpeg.exe", "to": "ffmpeg.exe" }, + { "from": "build/embedded/win32-${arch}/ffprobe.exe", "to": "ffprobe.exe" }, + { "from": "build/embedded/win32-${arch}/", "to": ".", "filter": ["*.dll"] } + ], "target": [ { "target": "nsis", "arch": ["x64"] }, { "target": "portable", "arch": ["x64"] } @@ -30,10 +40,19 @@ }, "mac": { "identity": null, + "extraResources": [ + { "from": "build/embedded/darwin-${arch}/ffmpeg", "to": "ffmpeg" }, + { "from": "build/embedded/darwin-${arch}/ffprobe", "to": "ffprobe" } + ], "target": [{ "target": "dmg", "arch": ["arm64", "x64"] }], "artifactName": "${productName}-${version}-${arch}.${ext}" }, "linux": { + "extraResources": [ + { "from": "build/embedded/linux-${arch}/ffmpeg", "to": "ffmpeg" }, + { "from": "build/embedded/linux-${arch}/ffprobe", "to": "ffprobe" }, + { "from": "build/embedded/linux-${arch}/", "to": ".", "filter": ["lib*.so*"] } + ], "target": [ { "target": "AppImage", "arch": ["x64"] }, { "target": "tar.gz", "arch": ["x64"] } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7fcfb6f2..3405e7ec 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,17 +8,22 @@ const require = createRequire(import.meta.url); const { version } = require('./package.json') as { version: string }; export default defineConfig(({ mode }) => { - // Inline APTABASE_KEY from .env at build time. Without this, process.env.APTABASE_KEY - // is undefined in the packaged app (no shell env to read from), and analytics silently - // never initialize. Empty-string prefix loads all keys regardless of VITE_/MAIN_VITE_ prefix. + // Inline OpenPanel credentials from .env at build time. Without this, + // process.env.OPENPANEL_CLIENT_ID is undefined in the packaged app (no + // shell env to read from) and analytics silently never initialize. + // Empty-string prefix loads all keys regardless of VITE_/MAIN_VITE_ prefix. const env = loadEnv(mode, '.', ''); - const aptabaseKey = env.APTABASE_KEY ?? process.env.APTABASE_KEY ?? ''; + const openpanelClientId = env.OPENPANEL_CLIENT_ID ?? process.env.OPENPANEL_CLIENT_ID ?? ''; + const openpanelClientSecret = env.OPENPANEL_CLIENT_SECRET ?? process.env.OPENPANEL_CLIENT_SECRET ?? ''; + const arroxyAnalyticsDebug = env.ARROXY_ANALYTICS_DEBUG ?? process.env.ARROXY_ANALYTICS_DEBUG ?? ''; return { main: { plugins: [externalizeDepsPlugin()], define: { - 'process.env.APTABASE_KEY': JSON.stringify(aptabaseKey), + 'process.env.OPENPANEL_CLIENT_ID': JSON.stringify(openpanelClientId), + 'process.env.OPENPANEL_CLIENT_SECRET': JSON.stringify(openpanelClientSecret), + 'process.env.ARROXY_ANALYTICS_DEBUG': JSON.stringify(arroxyAnalyticsDebug), }, resolve: { alias: { diff --git a/package.json b/package.json index 022d7628..6e0085b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arroxy", - "version": "0.3.0", + "version": "0.3.1-beta.2", "description": "Arroxy - YouTube downloader app", "main": "out/main/index.js", "author": { @@ -10,7 +10,8 @@ }, "license": "MIT", "scripts": { - "dev": "ELECTRON_DISABLE_SANDBOX=1 electron-vite dev", + "dev": "bun run embed:fetch:host && ELECTRON_DISABLE_SANDBOX=1 electron-vite dev", + "embed:fetch:host": "bash scripts/build/fetch-embedded.sh $(node -e \"console.log(process.platform)\") $(node -e \"console.log(process.arch === 'arm64' ? 'arm64' : 'x64')\")", "clean": "rm -rf dist out", "build": "bun run typecheck && electron-vite build", "dist": "bun run clean && bun run build && electron-builder --publish never", @@ -42,12 +43,12 @@ "prepare": "husky" }, "dependencies": { - "@aptabase/electron": "^0.3.1", "@base-ui/react": "^1.4.1", "@fontsource-variable/geist": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/outfit": "^5.2.8", "@fontsource/poppins": "^5.2.7", + "@openpanel/sdk": "^1.3.1", "@tanstack/react-virtual": "^3.13.24", "@testing-library/dom": "^10.4.1", "class-variance-authority": "^0.7.1", diff --git a/scripts/build/fetch-embedded.sh b/scripts/build/fetch-embedded.sh new file mode 100755 index 00000000..adbf735e --- /dev/null +++ b/scripts/build/fetch-embedded.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Fetch ffmpeg + ffprobe for ONE (platform, arch), verify SHA256, extract, +# place at build/embedded/-/{ffmpeg, ffprobe}[.exe] +# +# Invoked by build/beforeBuild.cjs (electron-builder lifecycle hook) per +# (platform, arch) the build is targeting. Also runnable standalone: +# bash scripts/build/fetch-embedded.sh linux x64 +# bash scripts/build/fetch-embedded.sh darwin arm64 +# bash scripts/build/fetch-embedded.sh win32 x64 +# +# Sources: +# - Win + Linux: BtbN/FFmpeg-Builds (gpl-shared variants), one archive +# contains both ffmpeg + ffprobe + DLLs. +# - macOS: ffmpeg.martin-riedl.de (GPL builds, two separate ZIPs). +# URL contains a snapshot timestamp+hash with no stable 'latest' alias, +# so we scrape the index page for the current snapshot path. +set -euo pipefail + +PLATFORM="${1:-}" +ARCH="${2:-}" +if [[ -z "$PLATFORM" || -z "$ARCH" ]]; then + echo "usage: $0 " >&2 + echo " platform: win32 | darwin | linux" >&2 + echo " arch: x64 | arm64" >&2 + exit 2 +fi + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$ROOT/build/embedded/${PLATFORM}-${ARCH}" +mkdir -p "$OUT" + +source "$ROOT/scripts/test-binaries/_lib.sh" + +# If both binaries are already present, skip — fetch-embedded is idempotent. +exe_ext="" +[[ "$PLATFORM" == "win32" ]] && exe_ext=".exe" +if [[ -f "$OUT/ffmpeg${exe_ext}" && -f "$OUT/ffprobe${exe_ext}" ]]; then + ok "embedded binaries already present at $OUT, skipping fetch" + exit 0 +fi + +# BtbN: single archive contains both ffmpeg + ffprobe (+ DLLs on Windows). +fetch_btbn() { + local platform="$1" arch="$2" out="$3" + local btbn_arch + case "${platform}-${arch}" in + win32-x64) btbn_arch=win64 ;; + win32-arm64) btbn_arch=winarm64 ;; + linux-x64) btbn_arch=linux64 ;; + linux-arm64) btbn_arch=linuxarm64 ;; + *) fail "fetch_btbn: unsupported ${platform}-${arch}"; exit 1 ;; + esac + local ext=tar.xz + [[ "$platform" == "win32" ]] && ext=zip + local asset="ffmpeg-master-latest-${btbn_arch}-gpl-shared.${ext}" + local base="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest" + local sums="$out/_sums" + + note "fetching BtbN $asset" + fetch "$base/checksums.sha256" "$sums" || exit 1 + fetch "$base/$asset" "$out/$asset" || exit 1 + local expected + expected=$(sha_for_asset "$sums" "$asset") + if [[ -z "$expected" ]]; then + fail "no SHA for $asset in BtbN checksums.sha256" + exit 1 + fi + verify_sha "$out/$asset" "$expected" "$asset" || exit 1 + + if [[ "$ext" == "zip" ]]; then + extract_zip "$out/$asset" "$out/_ext" || { fail "extract zip"; exit 1; } + else + extract_tarxz "$out/$asset" "$out/_ext" || { fail "extract tar.xz"; exit 1; } + fi + + local local_exe_ext="" + [[ "$platform" == "win32" ]] && local_exe_ext=".exe" + + local ffmpeg_src ffprobe_src bin_dir + ffmpeg_src=$(find "$out/_ext" -type f -name "ffmpeg${local_exe_ext}" | head -1) + ffprobe_src=$(find "$out/_ext" -type f -name "ffprobe${local_exe_ext}" | head -1) + if [[ -z "$ffmpeg_src" ]]; then fail "ffmpeg not in $asset"; exit 1; fi + if [[ -z "$ffprobe_src" ]]; then fail "ffprobe not in $asset"; exit 1; fi + cp "$ffmpeg_src" "$out/ffmpeg${local_exe_ext}" + cp "$ffprobe_src" "$out/ffprobe${local_exe_ext}" + bin_dir=$(dirname "$ffmpeg_src") + + if [[ "$platform" == "win32" ]]; then + # Win: bin/*.dll siblings ship next to the executables. Native DLL + # search picks them up from the executable's own dir. + find "$bin_dir" -maxdepth 1 -type f -name '*.dll' -exec cp -t "$out" {} + + else + # Linux: BtbN shared build keeps libav*.so* in /lib/. Copy them + # next to the binaries (preserve symlinks via cp -P) so we can resolve + # via LD_LIBRARY_PATH=$resourcesPath at runtime. + local lib_src_dir + lib_src_dir=$(dirname "$bin_dir")/lib + if [[ -d "$lib_src_dir" ]]; then + find "$lib_src_dir" -maxdepth 1 -name 'lib*.so*' -exec cp -P -t "$out" {} + + fi + fi + + chmod +x "$out/ffmpeg${local_exe_ext}" "$out/ffprobe${local_exe_ext}" 2>/dev/null || true + + rm -rf "$out/_ext" "$out/$asset" "$sums" + ok "BtbN ${btbn_arch} → $out" +} + +# Martin-Riedl: scrape index for current snapshot path, fetch two ZIPs. +fetch_martin_riedl() { + local arch="$1" out="$2" + local mr_arch + case "$arch" in + arm64) mr_arch=arm64 ;; + x64) mr_arch=amd64 ;; + *) fail "fetch_martin_riedl: unsupported arch $arch"; exit 1 ;; + esac + + local index="$out/_index.html" + note "fetching Martin-Riedl index for macos/${mr_arch}" + fetch "https://ffmpeg.martin-riedl.de/" "$index" || exit 1 + + # Index has many links per arch (snapshot + release). Take the first match, + # which is the snapshot block (newer than release). + local prefix + prefix=$(grep -oE "/download/macos/${mr_arch}/[0-9]+_N-[0-9a-zA-Z-]+/" "$index" | head -1) + if [[ -z "$prefix" ]]; then + fail "fetch_martin_riedl: cannot parse index for /download/macos/${mr_arch}/" + exit 1 + fi + local base="https://ffmpeg.martin-riedl.de${prefix}" + + for bin in ffmpeg ffprobe; do + note "fetching Martin-Riedl ${bin}.zip" + fetch "${base}${bin}.zip" "$out/${bin}.zip" || exit 1 + fetch "${base}${bin}.zip.sha256" "$out/${bin}.zip.sha256" || exit 1 + local expected + expected=$(awk '{print $1; exit}' "$out/${bin}.zip.sha256") + verify_sha "$out/${bin}.zip" "$expected" "${bin}.zip" || exit 1 + + extract_zip "$out/${bin}.zip" "$out/_ext_${bin}" || { fail "extract ${bin}.zip"; exit 1; } + local inner + inner=$(find "$out/_ext_${bin}" -type f -name "$bin" -not -path '*/__MACOSX/*' | head -1) + if [[ -z "$inner" ]]; then fail "$bin not in ${bin}.zip"; exit 1; fi + cp "$inner" "$out/$bin" + chmod +x "$out/$bin" + rm -rf "$out/_ext_${bin}" "$out/${bin}.zip" "$out/${bin}.zip.sha256" + done + + rm -f "$index" + ok "Martin-Riedl macos/${mr_arch} → $out" +} + +case "${PLATFORM}-${ARCH}" in + win32-x64|win32-arm64|linux-x64|linux-arm64) + fetch_btbn "$PLATFORM" "$ARCH" "$OUT" ;; + darwin-x64|darwin-arm64) + fetch_martin_riedl "$ARCH" "$OUT" ;; + *) + fail "unsupported target: ${PLATFORM}-${ARCH}" + exit 1 ;; +esac + +# Sanity: confirm both binaries land + are executable. +for bin in ffmpeg ffprobe; do + bin_path="$OUT/${bin}${exe_ext}" + if [[ ! -f "$bin_path" ]]; then + fail "missing $bin_path after fetch" + exit 1 + fi +done + +if (( FAIL > 0 )); then + echo "FAIL: $FAIL" + printf ' %s\n' "${ISSUES[@]}" + exit 1 +fi + +echo "[done] embedded ffmpeg + ffprobe at $OUT" diff --git a/scripts/test-binaries/_lib.sh b/scripts/test-binaries/_lib.sh new file mode 100644 index 00000000..289c71de --- /dev/null +++ b/scripts/test-binaries/_lib.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Shared helpers for fetch-embedded.sh (build) and smoke-all.sh (CI smoke). +# Source from another script: source "$(dirname "$0")/path/to/_lib.sh" + +PASS=0 +FAIL=0 +WARN=0 +declare -a ISSUES=() + +note() { echo "[ .. ] $*"; } +ok() { echo "[ OK ] $*"; PASS=$((PASS+1)); } +fail() { echo "[FAIL] $*"; FAIL=$((FAIL+1)); ISSUES+=("FAIL: $*"); } +warn() { echo "[WARN] $*"; WARN=$((WARN+1)); ISSUES+=("WARN: $*"); } + +# fetch URL into FILE. Follow redirects. Print failure on non-200. +# usage: fetch URL FILE +fetch() { + local url="$1" file="$2" + if [[ -f "$file" && -s "$file" ]]; then return 0; fi + mkdir -p "$(dirname "$file")" + local code + code=$(curl -fsSL --retry 3 --retry-delay 2 -o "$file" -w '%{http_code}' "$url" 2>/dev/null) || { + fail "fetch $url (http=$code)" + rm -f "$file" + return 1 + } + return 0 +} + +# verify file SHA matches expected hex +# usage: verify_sha FILE EXPECTED LABEL +verify_sha() { + local file="$1" expected="$2" label="$3" + local actual + actual=$(sha256sum "$file" | awk '{print $1}') + if [[ "$actual" == "$expected" ]]; then + ok "sha256 match: $label" + else + fail "sha256 mismatch: $label (expected ${expected:0:8}.., got ${actual:0:8}..)" + return 1 + fi +} + +# parse " " SHA2-256SUMS for a given asset +# usage: sha_for_asset SUMS_FILE ASSET_NAME +sha_for_asset() { + local sums="$1" asset="$2" + awk -v a="$asset" '$2==a {print $1; exit}' "$sums" +} + +# extract zip into dir +# usage: extract_zip ZIP DIR +extract_zip() { + local zip="$1" dir="$2" + mkdir -p "$dir" + unzip -q -o "$zip" -d "$dir" || return 1 +} + +# extract tar.xz into dir +# usage: extract_tarxz ARCHIVE DIR +extract_tarxz() { + local arc="$1" dir="$2" + mkdir -p "$dir" + tar -xJf "$arc" -C "$dir" || return 1 +} + +# check inner-binary magic bytes match expected target +# usage: check_magic FILE EXPECTED_PATTERN LABEL +check_magic() { + local file="$1" pattern="$2" label="$3" + if [[ ! -f "$file" ]]; then fail "missing inner binary: $label ($file)"; return; fi + local desc + desc=$(file -b "$file") + if [[ "$desc" =~ $pattern ]]; then + ok "magic: $label — $desc" + else + fail "magic mismatch: $label — got '$desc', wanted /$pattern/" + fi +} diff --git a/scripts/test-binaries/smoke-all.sh b/scripts/test-binaries/smoke-all.sh new file mode 100755 index 00000000..3c943e57 --- /dev/null +++ b/scripts/test-binaries/smoke-all.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# Smoke test: download every asset BinaryManager.ts can fetch, across all +# (platform, arch). Verify checksum where one is published. Run `file` on +# the inner executable to confirm magic bytes match the target platform. +# +# Asset matrix mirrors src/main/services/BinaryManager.ts. If you change +# URLs or asset names there, mirror the change here. +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$ROOT/test-binaries" +mkdir -p "$OUT" + +source "$(dirname "$0")/_lib.sh" + +########################################################################## +# yt-dlp — nightly + stable, 5 unique assets each +########################################################################## +echo +echo '########## yt-dlp ##########' + +declare -A YTDLP_ASSETS=( + [win32-x64]=yt-dlp.exe + [win32-arm64]=yt-dlp.exe + [darwin-x64]=yt-dlp_macos + [darwin-arm64]=yt-dlp_macos + [linux-x64]=yt-dlp_linux + [linux-arm64]=yt-dlp_linux_aarch64 +) + +for channel in nightly stable; do + if [[ "$channel" == nightly ]]; then + base=https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download + else + base=https://github.com/yt-dlp/yt-dlp/releases/latest/download + fi + sums="$OUT/yt-dlp/$channel/SHA2-256SUMS" + fetch "$base/SHA2-256SUMS" "$sums" || continue + ok "fetched $channel SHA2-256SUMS" + + declare -A seen_assets=() + for combo in "${!YTDLP_ASSETS[@]}"; do + asset="${YTDLP_ASSETS[$combo]}" + [[ -n "${seen_assets[$asset]:-}" ]] && continue + seen_assets[$asset]=1 + target="$OUT/yt-dlp/$channel/$asset" + note "fetching $channel/$asset" + fetch "$base/$asset" "$target" || continue + expected=$(sha_for_asset "$sums" "$asset") + if [[ -z "$expected" ]]; then + warn "no SHA listed for $channel/$asset" + else + verify_sha "$target" "$expected" "$channel/$asset" + fi + case "$asset" in + yt-dlp.exe) check_magic "$target" 'PE32(\+)? executable.*Windows' "$channel/$asset" ;; + yt-dlp_macos|yt-dlp_macos_legacy) check_magic "$target" 'Mach-O' "$channel/$asset" ;; + yt-dlp_linux*) check_magic "$target" 'ELF.*executable' "$channel/$asset" ;; + esac + done +done + +########################################################################## +# ffmpeg — eugeneware/ffmpeg-static b6.0 (linux + darwin, x64 + arm64) +########################################################################## +echo +echo '########## ffmpeg-static (eugeneware b6.0) ##########' + +EUGENE_BASE=https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0 +for combo in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do + asset="ffmpeg-$combo" + target="$OUT/ffmpeg-static/$combo/ffmpeg" + note "fetching $asset" + fetch "$EUGENE_BASE/$asset" "$target" || continue + shafile="$target.sha256" + if curl -fsSL "$EUGENE_BASE/$asset.sha256" -o "$shafile" 2>/dev/null; then + expected=$(awk '{print $1; exit}' "$shafile") + verify_sha "$target" "$expected" "$asset" + else + warn "no .sha256 sibling for $asset (eugeneware drops these for some assets)" + fi + case "$combo" in + linux-*) check_magic "$target" 'ELF.*executable' "$asset" ;; + darwin-*) check_magic "$target" 'Mach-O' "$asset" ;; + esac +done + +########################################################################## +# BtbN — Linux ffprobe tar.xz (x64 + arm64) +########################################################################## +echo +echo '########## BtbN Linux ffprobe (tar.xz) ##########' + +BTBN_BASE=https://github.com/BtbN/FFmpeg-Builds/releases/download/latest +btbn_sums="$OUT/btbn/checksums.sha256" +fetch "$BTBN_BASE/checksums.sha256" "$btbn_sums" || true + +for combo in linux64-gpl linuxarm64-gpl; do + asset="ffmpeg-master-latest-$combo.tar.xz" + target="$OUT/btbn-linux/$combo/$asset" + note "fetching $asset" + fetch "$BTBN_BASE/$asset" "$target" || continue + expected=$(sha_for_asset "$btbn_sums" "$asset") + if [[ -z "$expected" ]]; then + warn "no SHA for $asset in BtbN checksums.sha256" + else + verify_sha "$target" "$expected" "$asset" + fi + extract_dir="$OUT/btbn-linux/$combo/extracted" + rm -rf "$extract_dir" + if extract_tarxz "$target" "$extract_dir"; then + ok "extracted $asset" + inner=$(find "$extract_dir" -type f -name ffprobe | head -1) + if [[ -n "$inner" ]]; then + check_magic "$inner" 'ELF.*executable' "$asset/ffprobe" + else + fail "no ffprobe inside $asset" + fi + else + fail "extract failed: $asset" + fi +done + +########################################################################## +# BtbN — Windows shared zip (x64 + arm64) — fallback Windows ffmpeg pair +########################################################################## +echo +echo '########## BtbN Windows shared (zip) ##########' + +for combo in win64-lgpl-shared winarm64-lgpl-shared; do + asset="ffmpeg-master-latest-$combo.zip" + target="$OUT/btbn-windows/$combo/$asset" + note "fetching $asset" + fetch "$BTBN_BASE/$asset" "$target" || continue + expected=$(sha_for_asset "$btbn_sums" "$asset") + if [[ -z "$expected" ]]; then + warn "no SHA for $asset in BtbN checksums.sha256" + else + verify_sha "$target" "$expected" "$asset" + fi + extract_dir="$OUT/btbn-windows/$combo/extracted" + rm -rf "$extract_dir" + if extract_zip "$target" "$extract_dir"; then + ok "extracted $asset" + for exe in ffmpeg.exe ffprobe.exe; do + inner=$(find "$extract_dir" -type f -name "$exe" | head -1) + if [[ -n "$inner" ]]; then + check_magic "$inner" 'PE32(\+)? executable.*Windows' "$asset/$exe" + else + fail "no $exe inside $asset" + fi + done + else + fail "extract failed: $asset" + fi +done + +########################################################################## +# Gyan — Windows essentials zip (direct + GitHub mirror) +########################################################################## +echo +echo '########## Gyan Windows essentials ##########' + +GYAN_DIRECT=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip +note "fetching gyan-direct .sha256" +gyan_direct_sha_text="$OUT/gyan-direct/release-essentials.sha256" +mkdir -p "$(dirname "$gyan_direct_sha_text")" +if curl -fsSL "$GYAN_DIRECT.sha256" -o "$gyan_direct_sha_text" 2>/dev/null; then + ok "fetched gyan-direct .sha256" + expected=$(awk '{print $1; exit}' "$gyan_direct_sha_text") + target="$OUT/gyan-direct/ffmpeg-release-essentials.zip" + note "fetching gyan-direct zip" + fetch "$GYAN_DIRECT" "$target" || true + if [[ -f "$target" ]]; then + verify_sha "$target" "$expected" "gyan-direct/essentials.zip" + extract_dir="$OUT/gyan-direct/extracted" + rm -rf "$extract_dir" + if extract_zip "$target" "$extract_dir"; then + ok "extracted gyan-direct" + for exe in ffmpeg.exe ffprobe.exe; do + inner=$(find "$extract_dir" -type f -name "$exe" | head -1) + if [[ -n "$inner" ]]; then + check_magic "$inner" 'PE32(\+)? executable.*Windows' "gyan-direct/$exe" + else + fail "no $exe in gyan-direct" + fi + done + else + fail "extract failed: gyan-direct" + fi + fi +else + fail "gyan-direct .sha256 unavailable" +fi + +note "fetching gyan-github mirror release JSON" +GYAN_GH_API=https://api.github.com/repos/GyanD/codexffmpeg/releases/latest +gyan_gh_json="$OUT/gyan-github/release.json" +mkdir -p "$(dirname "$gyan_gh_json")" +if curl -fsSL "$GYAN_GH_API" -o "$gyan_gh_json" 2>/dev/null; then + ok "fetched gyan-github release json" + asset_url=$(jq -r '.assets[] | select(.name | endswith("essentials_build.zip")) | .browser_download_url' "$gyan_gh_json" | head -1) + asset_digest=$(jq -r '.assets[] | select(.name | endswith("essentials_build.zip")) | .digest // empty' "$gyan_gh_json" | head -1) + asset_name=$(jq -r '.assets[] | select(.name | endswith("essentials_build.zip")) | .name' "$gyan_gh_json" | head -1) + if [[ -z "$asset_url" ]]; then + fail "gyan-github: essentials_build.zip not in latest release" + else + ok "gyan-github asset: $asset_name" + target="$OUT/gyan-github/$asset_name" + note "fetching gyan-github $asset_name" + fetch "$asset_url" "$target" || true + if [[ -f "$target" ]]; then + if [[ "$asset_digest" =~ ^sha256:([a-f0-9]{64})$ ]]; then + verify_sha "$target" "${BASH_REMATCH[1]}" "gyan-github/$asset_name" + else + warn "gyan-github asset has no sha256 digest in API" + fi + fi + fi +else + fail "gyan-github release JSON fetch failed" +fi + +########################################################################## +# evermeet.cx — macOS ffprobe zip (primary, redirects) +########################################################################## +echo +echo '########## evermeet macOS ffprobe (primary) ##########' + +EVERMEET=https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip +target="$OUT/evermeet/ffprobe.zip" +note "fetching evermeet ffprobe" +if fetch "$EVERMEET" "$target"; then + warn "evermeet has no published checksum (skipped sha verify)" + extract_dir="$OUT/evermeet/extracted" + rm -rf "$extract_dir" + if extract_zip "$target" "$extract_dir"; then + ok "extracted evermeet ffprobe.zip" + inner=$(find "$extract_dir" -type f -name ffprobe | head -1) + if [[ -n "$inner" ]]; then + check_magic "$inner" 'Mach-O' "evermeet/ffprobe" + else + fail "no ffprobe in evermeet zip" + fi + else + fail "extract failed: evermeet" + fi +fi + +########################################################################## +# osxexperts.net — macOS ffprobe fallback (pinned 7.1, both archs) +########################################################################## +echo +echo '########## osxexperts macOS ffprobe (fallback) ##########' + +for combo in arm intel; do + asset="ffprobe71$combo.zip" + target="$OUT/osxexperts/$combo/$asset" + note "fetching $asset" + if fetch "https://www.osxexperts.net/$asset" "$target"; then + warn "osxexperts has no published checksum (skipped sha verify)" + extract_dir="$OUT/osxexperts/$combo/extracted" + rm -rf "$extract_dir" + if extract_zip "$target" "$extract_dir"; then + ok "extracted $asset" + inner=$(find "$extract_dir" -type f -name ffprobe -not -path '*/__MACOSX/*' | head -1) + if [[ -n "$inner" ]]; then + check_magic "$inner" 'Mach-O' "osxexperts/$combo" + else + fail "no ffprobe in $asset" + fi + else + fail "extract failed: $asset" + fi + fi +done + +########################################################################## +# deno — 5 targets (zip), .sha256sum sibling +########################################################################## +echo +echo '########## deno ##########' + +DENO_BASE=https://github.com/denoland/deno/releases/latest/download +for target_triple in \ + x86_64-pc-windows-msvc \ + x86_64-apple-darwin \ + aarch64-apple-darwin \ + x86_64-unknown-linux-gnu \ + aarch64-unknown-linux-gnu +do + asset="deno-$target_triple.zip" + target="$OUT/deno/$target_triple/$asset" + note "fetching $asset" + fetch "$DENO_BASE/$asset" "$target" || continue + shafile="$target.sha256sum" + if curl -fsSL "$DENO_BASE/$asset.sha256sum" -o "$shafile" 2>/dev/null; then + # Linux/macOS .sha256sum is POSIX " "; Windows is PowerShell + # Get-FileHash with "Hash : " lines. + expected=$(grep -oE '^Hash[[:space:]]*:[[:space:]]*[a-fA-F0-9]{64}' "$shafile" | awk '{print tolower($NF)}' | head -1) + if [[ -z "$expected" ]]; then + expected=$(awk 'NF>0 && $1 ~ /^[a-fA-F0-9]{64}$/ {print tolower($1); exit}' "$shafile") + fi + if [[ -z "$expected" ]]; then + warn "could not parse $asset.sha256sum" + else + verify_sha "$target" "$expected" "deno/$target_triple" + fi + else + warn "no .sha256sum for $asset" + fi + extract_dir="$OUT/deno/$target_triple/extracted" + rm -rf "$extract_dir" + if extract_zip "$target" "$extract_dir"; then + ok "extracted $asset" + case "$target_triple" in + *-windows-*) inner_name=deno.exe; pattern='PE32(\+)? executable.*Windows' ;; + *-apple-*) inner_name=deno; pattern='Mach-O' ;; + *-linux-*) inner_name=deno; pattern='ELF.*executable' ;; + esac + inner=$(find "$extract_dir" -type f -name "$inner_name" | head -1) + if [[ -n "$inner" ]]; then + check_magic "$inner" "$pattern" "deno/$target_triple" + else + fail "no $inner_name inside $asset" + fi + else + fail "extract failed: $asset" + fi +done + +echo +echo '########## SUMMARY ##########' +echo "PASS: $PASS" +echo "WARN: $WARN" +echo "FAIL: $FAIL" +if (( ${#ISSUES[@]} > 0 )); then + echo + echo 'Issues:' + printf ' %s\n' "${ISSUES[@]}" +fi +exit $(( FAIL > 0 ? 1 : 0 )) diff --git a/src/main/index.ts b/src/main/index.ts index 066bafbc..4f695b37 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -122,9 +122,6 @@ function createMainWindow(): BrowserWindow { } if (hasSingleInstanceLock) { - // Must be called before app.isReady() so aptabase registers its custom protocol scheme. - setupAnalytics(process.env.APTABASE_KEY, !!process.env.ELECTRON_RENDERER_URL || isMockBackend || !!process.env.ARROXY_SMOKE_URL); - void app.whenReady().then(async () => { const userDataPath = app.getPath('userData'); log.transports.file.resolvePathFn = () => path.join(userDataPath, 'logs', 'main.log'); @@ -137,6 +134,22 @@ if (hasSingleInstanceLock) { const settingsStore = new SettingsStore(userDataPath, defaultAppSettings(app.getPath('downloads'))); const initialSettings = await settingsStore.get(); + // installId is stamped lazily by SettingsStore on first launch — guaranteed + // present after `get()`. Empty string fallback keeps TS happy without + // weakening the type elsewhere. + const installId = initialSettings.common.installId ?? ''; + const isDev = !!process.env.ELECTRON_RENDERER_URL || isMockBackend || !!process.env.ARROXY_SMOKE_URL; + const cpuModel = os.cpus()[0]?.model ?? 'unknown'; + const osLocale = app.getLocale(); + setupAnalytics(process.env.OPENPANEL_CLIENT_ID, process.env.OPENPANEL_CLIENT_SECRET, isDev, installId, { + appVersion: app.getVersion(), + platform: process.platform, + architecture: process.arch, + systemVersion: os.release(), + modelName: cpuModel, + osLocale, + appLocale: initialSettings.common.language ?? osLocale + }); const languageRef: { current: ReturnType } = { current: pickLanguage(initialSettings.common.language ?? app.getLocale()) }; diff --git a/src/main/services/BinaryManager.ts b/src/main/services/BinaryManager.ts index 02a0f454..0ee2fd8d 100644 --- a/src/main/services/BinaryManager.ts +++ b/src/main/services/BinaryManager.ts @@ -5,13 +5,14 @@ import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; +import { app } from 'electron'; import extractZip from 'extract-zip'; import got, { type Method } from 'got'; import log from 'electron-log/main'; const execFileAsync = promisify(execFile); import { trackMain } from '@main/services/analytics'; -import type { BinaryOverrides, DependencyAttempt, DependencyDiagnostic, DependencyFailure, DependencyFailureKind, DependencyId, DependencySource, StatusKey, WarmupProgressEvent } from '@shared/types'; +import { FAILURE_CODE, type BinaryOverrides, type DependencyAttempt, type DependencyDiagnostic, type DependencyFailure, type DependencyFailureKind, type DependencyId, type DependencySource, type StatusKey, type WarmupProgressEvent } from '@shared/types'; type StatusReporter = (statusKey: StatusKey, params?: Record) => void; type DownloadProgressCallback = (downloaded: number, total: number | undefined) => void; @@ -35,6 +36,14 @@ function isAbortError(err: unknown): boolean { return err.name === 'AbortError' || (err as { code?: string }).code === 'ABORT_ERR'; } +// Cancellation errors must keep their AbortError marker so classifyDownloadError +// → 'timeout'. A plain `new Error('Cancelled')` reads as a generic download_failed. +function cancelError(message = 'Cancelled'): Error { + const err = new Error(message); + err.name = 'AbortError'; + return err; +} + function abortFailure(message: string): DependencyFailure { return { kind: 'timeout', message, osCode: 'CANCELLED' }; } @@ -64,9 +73,13 @@ function classifyProbeError(err: NodeJS.ErrnoException, stderr?: string): Depend async function probeBinary(filePath: string, args: string[], timeoutMs: number = PROBE_TIMEOUT_MS, signal?: AbortSignal): Promise<{ ok: true; output: string } | { ok: false; failure: DependencyFailure }> { if (signal?.aborted) return { ok: false, failure: abortFailure('Cancelled before probe') }; + // BtbN's Linux shared ffmpeg/ffprobe build expects libav*.so.* siblings in + // the executable's own directory. Inject LD_LIBRARY_PATH so probing the + // bundled binary works the same way spawnYtDlp/spawnFFmpeg do at runtime. + const env = process.platform === 'linux' ? { ...process.env, LD_LIBRARY_PATH: path.dirname(filePath) + (process.env.LD_LIBRARY_PATH ? path.delimiter + process.env.LD_LIBRARY_PATH : '') } : undefined; return new Promise((resolve) => { let settled = false; - const child = execFile(filePath, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024, signal }, (err, stdout, stderr) => { + const child = execFile(filePath, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024, signal, env }, (err, stdout, stderr) => { if (settled) return; settled = true; if (err) { @@ -137,6 +150,7 @@ function failedDiagnostic(id: DependencyId, attempts: DependencyAttempt[]): Depe } function classifyDownloadError(err: unknown): DependencyFailureKind { + if (isAbortError(err)) return 'timeout'; const msg = err instanceof Error ? err.message.toLowerCase() : ''; if (msg.includes('checksum')) return 'hash_failed'; if (msg.includes('archive') || msg.includes('did not contain') || msg.includes('extract')) return 'extract_failed'; @@ -188,6 +202,22 @@ function parseShaLine(content: string, fileName: string): string | null { return null; } +// Single-line plain-hex SHA used by deno's Linux/Mac sha256sum sibling. +// Falls back to any 64-hex token in the body (covers labelled forms). +function parseStandaloneSha256(content: string): string | null { + const firstToken = content.trim().split(/\s+/)[0] ?? ''; + if (/^[a-fA-F0-9]{64}$/.test(firstToken)) return firstToken.toLowerCase(); + const labelled = /\b([a-fA-F0-9]{64})\b/.exec(content); + return labelled ? labelled[1].toLowerCase() : null; +} + +// Deno Windows .sha256sum uses multi-line "Hash : <64-hex>" PowerShell +// format. Linux/Mac use parseStandaloneSha256 instead. +function parsePowerShellFileHash(content: string): string | null { + const match = /^\s*Hash\s*:\s*([a-fA-F0-9]{64})\s*$/m.exec(content); + return match ? match[1].toLowerCase() : null; +} + // Network plumbing handled by `got`: per-phase timeouts (DNS/TCP/TLS/header/idle/total), // jittered exponential backoff, automatic redirect handling with retry on transient // HTTP errors (408/429/5xx) and network errors (ECONNRESET/ETIMEDOUT/EAI_AGAIN/...). @@ -217,7 +247,7 @@ async function downloadText(url: string, signal?: AbortSignal): Promise if (signal) { if (signal.aborted) { req.cancel(); - throw new Error('Cancelled'); + throw cancelError(); } const onAbort = (): void => req.cancel(); signal.addEventListener('abort', onAbort, { once: true }); @@ -270,7 +300,7 @@ function resolvePartialResponseMode(startByte: number, statusCode: number | unde // attempt, resume via `Range: bytes=-`. If the server responds 200 // (no range support) instead of 206, truncate and start fresh. async function downloadFile(url: string, destination: string, onProgress?: DownloadProgressCallback, allowPartialRetry = true, signal?: AbortSignal): Promise { - if (signal?.aborted) throw new Error('Cancelled'); + if (signal?.aborted) throw cancelError(); await fsPromises.mkdir(path.dirname(destination), { recursive: true }); const partPath = `${destination}.part`; @@ -287,7 +317,7 @@ async function downloadFile(url: string, destination: string, onProgress?: Downl const stream = got.stream(url, { headers, retry: HTTP_RETRY, timeout: HTTP_TIMEOUT, followRedirect: true }); const onAbort = (): void => { - stream.destroy(new Error('Cancelled')); + stream.destroy(cancelError()); }; if (signal) { if (signal.aborted) onAbort(); @@ -350,9 +380,9 @@ async function sha256ForFile(filePath: string): Promise { }); } -// One place for every upstream URL/host the bootstrap touches. Keep this -// block as the single grep target — every code path that downloads a binary -// or hits a release index reads from here. +// Upstream sources still reached at runtime. ffmpeg + ffprobe are no longer +// here — they ship via electron-builder extraResources (see fetch-embedded.sh +// + bundledBinaryPath above). Only yt-dlp + deno remain runtime-fetched. const BINARY_SOURCES = { ytDlpNightly: { download: 'https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download', @@ -364,42 +394,20 @@ const BINARY_SOURCES = { }, deno: { download: 'https://github.com/denoland/deno/releases/latest/download' - }, - ffmpegStatic: { - // eugeneware/ffmpeg-static, pinned to b6.0 (last tag with full platform - // matrix). Used on Linux/macOS as the single ffmpeg binary. - download: 'https://github.com/eugeneware/ffmpeg-static/releases/download/b6.0' - }, - ffmpegBtbn: { - // BtbN — Linux ffprobe (tar.xz, contains bin/ffprobe). - download: 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest' - }, - ffmpegGyan: { - // gyan.dev — Win ffmpeg + ffprobe pair (essentials ZIP, ~30 MB). - download: 'https://www.gyan.dev/ffmpeg/builds', - essentialsArchive: 'ffmpeg-release-essentials.zip' - }, - ffmpegEvermeet: { - // evermeet.cx — macOS ffprobe (.zip, redirects to latest). - ffprobeZip: 'https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip' } } as const; type AssetPlatform = 'win32' | 'darwin' | 'linux'; type AssetArch = 'arm64' | 'x64'; +// yt-dlp_macos is a Mach-O universal binary (x86_64 + arm64); yt-dlp_macos_legacy +// was discontinued upstream and now 404s on every release tag. const YT_DLP_ASSETS: Record> = { win32: { x64: 'yt-dlp.exe', arm64: 'yt-dlp.exe' }, - darwin: { x64: 'yt-dlp_macos_legacy', arm64: 'yt-dlp_macos' }, + darwin: { x64: 'yt-dlp_macos', arm64: 'yt-dlp_macos' }, linux: { x64: 'yt-dlp_linux', arm64: 'yt-dlp_linux_aarch64' } }; -const FFMPEG_ASSETS: Record> = { - win32: { x64: 'ffmpeg-win32-x64', arm64: 'ffmpeg-win32-arm64' }, - darwin: { x64: 'ffmpeg-darwin-x64', arm64: 'ffmpeg-darwin-arm64' }, - linux: { x64: 'ffmpeg-linux-x64', arm64: 'ffmpeg-linux-arm64' } -}; - // Deno releases ship as ZIPs named deno-.zip on the GitHub release // page. The archive contains a single binary (deno or deno.exe). // Note: Windows ARM64 has no official deno build yet — null falls back to no JS runtime. @@ -409,52 +417,6 @@ const DENO_ASSETS: Record> = { linux: { x64: 'x86_64-unknown-linux-gnu', arm64: 'aarch64-unknown-linux-gnu' } }; -// ffprobe is shipped alongside ffmpeg in the canonical FFmpeg distributions. -// We pull it at runtime instead of bundling via @ffprobe-installer/* npm -// optional deps, which were unreliable on cross-platform CI: bun's frozen -// lockfile sometimes skips the host-platform optional, and electron-builder -// can't unpack what was never installed. -// -// - Win/Linux: BtbN/FFmpeg-Builds — single `latest` rolling tag, archives -// contain bin/ffprobe(.exe). Linux is .tar.xz (extracted via system tar). -// - macOS: evermeet.cx — ships ffprobe as a standalone .zip; the -// /getrelease/ffprobe/zip endpoint redirects to the latest version. -type FfprobeArchive = { source: 'btbn'; archive: string; format: 'zip' | 'tar.xz' } | { source: 'gyan'; archive: string; format: 'zip' } | { source: 'evermeet'; format: 'zip' }; - -const FFPROBE_ASSETS: Record> = { - win32: { - // gyan.dev "essentials" build: ~30 MB ffprobe.exe vs ~197 MB for BtbN GPL - // (which statically links every codec/filter we don't use). Smaller transfer - // = lower stall risk during warmup, less disk usage per user. - x64: { source: 'gyan', archive: 'ffmpeg-release-essentials.zip', format: 'zip' }, - arm64: null - }, - linux: { - x64: { source: 'btbn', archive: 'ffmpeg-master-latest-linux64-gpl.tar.xz', format: 'tar.xz' }, - arm64: { source: 'btbn', archive: 'ffmpeg-master-latest-linuxarm64-gpl.tar.xz', format: 'tar.xz' } - }, - darwin: { - x64: { source: 'evermeet', format: 'zip' }, - arm64: { source: 'evermeet', format: 'zip' } - } -}; - -function ffprobeAsset(): FfprobeArchive | null { - const target = currentAssetTarget(); - if (!target) return null; - return FFPROBE_ASSETS[target.platform][target.arch]; -} - -function ffprobeDownloadUrl(asset: FfprobeArchive): string { - if (asset.source === 'btbn') return `${BINARY_SOURCES.ffmpegBtbn.download}/${asset.archive}`; - if (asset.source === 'gyan') return `${BINARY_SOURCES.ffmpegGyan.download}/${asset.archive}`; - return BINARY_SOURCES.ffmpegEvermeet.ffprobeZip; -} - -function ffprobeExecutableName(): string { - return process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'; -} - function currentAssetTarget(): { platform: AssetPlatform; arch: AssetArch } | null { const platform = process.platform; if (platform !== 'win32' && platform !== 'darwin' && platform !== 'linux') return null; @@ -468,12 +430,6 @@ function ytDlpAssetName(): string { return YT_DLP_ASSETS[target.platform][target.arch]; } -function ffmpegAssetName(): string | null { - const target = currentAssetTarget(); - if (!target) return null; - return FFMPEG_ASSETS[target.platform][target.arch]; -} - function denoAssetTarget(): string | null { const target = currentAssetTarget(); if (!target) return null; @@ -489,6 +445,34 @@ function denoExecutableName(): string { return process.platform === 'win32' ? 'deno.exe' : 'deno'; } +// Resolve absolute path to a build-time-embedded ffmpeg/ffprobe binary. +// +// Production: binaries ship via electron-builder `extraResources`, so they +// land in `process.resourcesPath` (Mac: Arroxy.app/Contents/Resources, Win: +// /resources, Linux AppImage: /tmp/.mount_*/resources). +// +// Development: scripts/build/fetch-embedded.sh populates +// build/embedded/-/ once before `bun run dev`, so the +// dev branch reads from there to mirror the production layout. +function bundledBinaryPath(name: 'ffmpeg' | 'ffprobe'): string { + const ext = process.platform === 'win32' ? '.exe' : ''; + const fileName = `${name}${ext}`; + if (app.isPackaged) { + return path.join(process.resourcesPath, fileName); + } + const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; + // __dirname in dev points at the electron-vite-compiled main bundle + // (out/main). Resolve up to repo root, then into build/embedded/. + return path.join(__dirname, '..', '..', 'build', 'embedded', `${process.platform}-${arch}`, fileName); +} + +// Directory containing the embedded ffmpeg/ffprobe pair. Used by +// spawnYtDlp + spawnFFmpeg to set LD_LIBRARY_PATH (Linux) so BtbN's +// shared libav*.so.* siblings resolve. +// Bound recursion so a malicious archive (deep tree or symlink cycle) cannot +// stall extraction. Used by deno's zip extractor. +const ARCHIVE_TREE_MAX_DEPTH = 8; + interface EnsureBinaryConfig { name: string; destinationPath: string; @@ -542,7 +526,7 @@ export class BinaryManager { } getFfmpegPath(): string { - return this.resolved.ffmpeg ?? process.env.ARROXY_FFMPEG_PATH ?? path.join(this.cacheDir, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); + return this.resolved.ffmpeg ?? process.env.ARROXY_FFMPEG_PATH ?? bundledBinaryPath('ffmpeg'); } getDenoPath(): string { @@ -550,7 +534,7 @@ export class BinaryManager { } getFfprobePath(): string { - return this.resolved.ffprobe ?? process.env.ARROXY_FFPROBE_PATH ?? path.join(this.cacheDir, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); + return this.resolved.ffprobe ?? process.env.ARROXY_FFPROBE_PATH ?? bundledBinaryPath('ffprobe'); } // Probe-and-record helper used by every resolve chain. Runs the binary's @@ -667,6 +651,15 @@ export class BinaryManager { // Wraps a managed-download attempt, recording download/extract/hash failures // as attempts on the chain. Returns true if the file is on disk after the // call (probe still has to run separately). + private recordManagedFailure(id: DependencyId, attempts: DependencyAttempt[], source: DependencySource, onProgress: ProgressEmitter | undefined, err: unknown): void { + const failure: DependencyFailure = { kind: classifyDownloadError(err), message: errorMessage(err) }; + attempts.push(makeAttempt(source, failure)); + onProgress?.({ binary: id, phase: 'failed', source, failureKind: failure.kind }); + const tracked = id === 'yt-dlp' ? 'ytdlp' : id; + trackMain('binary_setup_failed', { binary: tracked, phase: failure.kind, code: FAILURE_CODE[failure.kind] }); + logger.warn(`${id} managed download failed`, { source, error: failure.message }); + } + private async tryManagedDownload(id: DependencyId, attempts: DependencyAttempt[], source: DependencySource, onProgress: ProgressEmitter | undefined, run: () => Promise): Promise { onProgress?.({ binary: id, phase: 'downloading', source }); try { @@ -674,12 +667,7 @@ export class BinaryManager { onProgress?.({ binary: id, phase: 'extracting', source }); return true; } catch (err) { - const failure: DependencyFailure = { kind: classifyDownloadError(err), message: errorMessage(err) }; - attempts.push(makeAttempt(source, failure)); - onProgress?.({ binary: id, phase: 'failed', source, failureKind: failure.kind }); - const tracked = id === 'yt-dlp' ? 'ytdlp' : id; - trackMain('binary_setup_failed', { binary: tracked, phase: failure.kind }); - logger.warn(`${id} managed download failed`, { source, error: failure.message }); + this.recordManagedFailure(id, attempts, source, onProgress, err); return false; } } @@ -708,223 +696,49 @@ export class BinaryManager { // spawnYtDlp's existing PATH injection finds both with one PATH entry. // Returns null if the platform/arch has no upstream build; the caller // tolerates this (ffprobe is only needed by certain post-processors). - // FFmpeg and ffprobe must be a matched pair: yt-dlp post-processors expect - // both side-by-side on PATH, and version skew between them tends to break - // codec/container handling. On Windows we pull a single Gyan archive that - // bundles both; on Linux/macOS we resolve each from its current upstream. + // ffmpeg + ffprobe ship via electron-builder extraResources at build time. + // Resolve order per binary: manualOverride → envOverride → bundled probe. + // No download/extract/checksum/retry — fetch-embedded.sh did all that during + // CI build. Pair coherence solved by construction (one matched archive → + // both binaries land together in process.resourcesPath). async resolveFFmpegPair(opts: ResolveOptions = {}): Promise<{ ffmpeg: DependencyDiagnostic; ffprobe: DependencyDiagnostic }> { - return process.platform === 'win32' ? this.resolveFFmpegPairWin(opts) : this.resolveFFmpegPairUnix(opts); - } - - private async resolveFFmpegPairWin(opts: ResolveOptions): Promise<{ ffmpeg: DependencyDiagnostic; ffprobe: DependencyDiagnostic }> { const overrides = opts.overrides ?? this.overridesProvider(); const onProgress = opts.onProgress; const signal = opts.signal; - const ffmpegAttempts: DependencyAttempt[] = []; - const ffprobeAttempts: DependencyAttempt[] = []; - onProgress?.({ binary: 'ffmpeg', phase: 'starting' }); - onProgress?.({ binary: 'ffprobe', phase: 'starting' }); - - // Manual overrides — pair only succeeds if both probe-pass. - const manualPair = await this.tryPairOverride(overrides?.ffmpeg, overrides?.ffprobe, 'manualOverride', undefined, ffmpegAttempts, ffprobeAttempts, onProgress, signal); - if (manualPair) return manualPair; - - const envFfmpeg = process.env.ARROXY_FFMPEG_PATH; - const envFfprobe = process.env.ARROXY_FFPROBE_PATH; - const envPair = await this.tryPairOverride(envFfmpeg, envFfprobe, 'envOverride', { ffmpeg: 'ARROXY_FFMPEG_PATH', ffprobe: 'ARROXY_FFPROBE_PATH' }, ffmpegAttempts, ffprobeAttempts, onProgress, signal); - if (envPair) return envPair; - - // Managed Gyan essentials ZIP — contains bin/ffmpeg.exe + bin/ffprobe.exe. - const pairDir = path.join(this.cacheDir, 'ffmpeg-pair'); - const ffmpegPath = path.join(pairDir, 'ffmpeg.exe'); - const ffprobePath = path.join(pairDir, 'ffprobe.exe'); - const archiveUrl = `${BINARY_SOURCES.ffmpegGyan.download}/${BINARY_SOURCES.ffmpegGyan.essentialsArchive}`; - const managedSource: DependencySource = { kind: 'managed', channel: 'default', url: archiveUrl }; - - const pairAlreadyExists = (await this.isUsableBinary(ffmpegPath)) && (await this.isUsableBinary(ffprobePath)); - const downloadOk = pairAlreadyExists - ? true - : await this.tryManagedDownload('ffmpeg', ffmpegAttempts, managedSource, onProgress, () => - this.ensureZippedBinaryMulti({ - name: 'ffmpeg-pair', - downloadUrl: archiveUrl, - zipFileName: 'ffmpeg-release-essentials.zip', - members: [ - { innerName: 'ffmpeg.exe', destinationPath: ffmpegPath }, - { innerName: 'ffprobe.exe', destinationPath: ffprobePath } - ], - expectedSha256: () => Promise.resolve(null), - onStatus: opts.onStatus, - onDownloadProgress: makeDownloadProgress('ffmpeg', managedSource, onProgress), - signal - }) - ); - - if (downloadOk) { - const ffmpegDiag = await this.probeAndAccept('ffmpeg', managedSource, ffmpegPath, ffmpegAttempts, onProgress, signal); - const ffprobeDiag = await this.probeAndAccept('ffprobe', managedSource, ffprobePath, ffprobeAttempts, onProgress, signal); - if (ffmpegDiag && ffprobeDiag) return { ffmpeg: ffmpegDiag, ffprobe: ffprobeDiag }; - } else { - // Mirror the failure on ffprobe so the diagnostic surfaces it too. - const last = ffmpegAttempts[ffmpegAttempts.length - 1]; - if (last?.failure) ffprobeAttempts.push(makeAttempt(managedSource, last.failure)); - } - - // System PATH pair: both binaries must come from the same directory. - onProgress?.({ binary: 'ffmpeg', phase: 'fallback' }); - onProgress?.({ binary: 'ffprobe', phase: 'fallback' }); - const ffmpegCandidates = await whereOnPath('ffmpeg.exe', signal); - const probeCandidates = new Set((await whereOnPath('ffprobe.exe', signal)).map((p) => path.dirname(p).toLowerCase())); - for (const ffmpegCandidate of ffmpegCandidates) { - const dir = path.dirname(ffmpegCandidate); - if (!probeCandidates.has(dir.toLowerCase())) continue; - const probeCandidate = path.join(dir, 'ffprobe.exe'); - const ffmpegSource: DependencySource = { kind: 'systemPath', path: ffmpegCandidate }; - const probeSource: DependencySource = { kind: 'systemPath', path: probeCandidate }; - const ffmpegDiag = await this.probeAndAccept('ffmpeg', ffmpegSource, ffmpegCandidate, ffmpegAttempts, onProgress, signal); - if (!ffmpegDiag) continue; - const ffprobeDiag = await this.probeAndAccept('ffprobe', probeSource, probeCandidate, ffprobeAttempts, onProgress, signal); - if (ffprobeDiag) return { ffmpeg: ffmpegDiag, ffprobe: ffprobeDiag }; - // ffmpeg succeeded but ffprobe didn't — the pair is incomplete; drop ffmpeg too. - delete this.resolved.ffmpeg; - } - const ffmpegDiag = failedDiagnostic('ffmpeg', ffmpegAttempts); - const ffprobeDiag = failedDiagnostic('ffprobe', ffprobeAttempts); - this.lastDiagnostics.ffmpeg = ffmpegDiag; - this.lastDiagnostics.ffprobe = ffprobeDiag; - return { ffmpeg: ffmpegDiag, ffprobe: ffprobeDiag }; - } + const resolveOne = async (id: 'ffmpeg' | 'ffprobe', overridePath: string | undefined, envVar: string): Promise => { + const attempts: DependencyAttempt[] = []; + onProgress?.({ binary: id, phase: 'starting' }); - // Pair-override helper for manual + env paths. Both halves must be set and - // both must probe. Half-set is treated as an explicit pair_incomplete failure - // on whichever side is missing, then the chain advances. - private async tryPairOverride(ffmpegPath: string | undefined, ffprobePath: string | undefined, kind: 'manualOverride' | 'envOverride', envVars: { ffmpeg: string; ffprobe: string } | undefined, ffmpegAttempts: DependencyAttempt[], ffprobeAttempts: DependencyAttempt[], onProgress: ProgressEmitter | undefined, signal?: AbortSignal): Promise<{ ffmpeg: DependencyDiagnostic; ffprobe: DependencyDiagnostic } | null> { - if (!ffmpegPath && !ffprobePath) return null; - const buildSource = (which: 'ffmpeg' | 'ffprobe', filePath: string): DependencySource => { - if (kind === 'envOverride' && envVars) return { kind: 'envOverride', path: filePath, envVar: envVars[which] }; - return { kind: 'manualOverride', path: filePath }; - }; - - if (!ffmpegPath || !ffprobePath) { - const missing: DependencyFailure = { kind: 'pair_incomplete', message: 'ffmpeg and ffprobe overrides must both be set' }; - if (ffmpegPath) { - const source = buildSource('ffmpeg', ffmpegPath); - ffmpegAttempts.push(makeAttempt(source, missing)); - onProgress?.({ binary: 'ffmpeg', phase: 'failed', source, failureKind: missing.kind }); - } - if (ffprobePath) { - const source = buildSource('ffprobe', ffprobePath); - ffprobeAttempts.push(makeAttempt(source, missing)); - onProgress?.({ binary: 'ffprobe', phase: 'failed', source, failureKind: missing.kind }); + if (overridePath) { + const source: DependencySource = { kind: 'manualOverride', path: overridePath }; + const diag = await this.probeAndAccept(id, source, overridePath, attempts, onProgress, signal); + if (diag) return diag; } - return null; - } - - const ffmpegSource = buildSource('ffmpeg', ffmpegPath); - const ffprobeSource = buildSource('ffprobe', ffprobePath); - const ffmpegDiag = await this.probeAndAccept('ffmpeg', ffmpegSource, ffmpegPath, ffmpegAttempts, onProgress, signal); - const ffprobeDiag = await this.probeAndAccept('ffprobe', ffprobeSource, ffprobePath, ffprobeAttempts, onProgress, signal); - if (ffmpegDiag && ffprobeDiag) return { ffmpeg: ffmpegDiag, ffprobe: ffprobeDiag }; - if (ffmpegDiag && !ffprobeDiag) delete this.resolved.ffmpeg; - if (!ffmpegDiag && ffprobeDiag) delete this.resolved.ffprobe; - return null; - } - // Non-Windows: keep the existing per-binary upstreams (eugeneware ffmpeg, - // BtbN/evermeet/Gyan ffprobe) but gate each on a runnable probe. - private async resolveFFmpegPairUnix(opts: ResolveOptions): Promise<{ ffmpeg: DependencyDiagnostic; ffprobe: DependencyDiagnostic }> { - const onProgress = opts.onProgress; - const overrides = opts.overrides ?? this.overridesProvider(); - const signal = opts.signal; + const envPath = process.env[envVar]; + if (envPath) { + const source: DependencySource = { kind: 'envOverride', path: envPath, envVar }; + const diag = await this.probeAndAccept(id, source, envPath, attempts, onProgress, signal); + if (diag) return diag; + } - const ffmpegDiag = await this.resolveSingleBinary( - 'ffmpeg', - overrides?.ffmpeg, - process.env.ARROXY_FFMPEG_PATH, - 'ARROXY_FFMPEG_PATH', - async (source, attempts) => { - const assetName = ffmpegAssetName(); - if (!assetName) return null; - const targetPath = path.join(this.cacheDir, 'ffmpeg'); - const url = `${BINARY_SOURCES.ffmpegStatic.download}/${assetName}`; - const checksumUrl = `${url}.sha256`; - const downloadOk = await this.tryManagedDownload('ffmpeg', attempts, source, onProgress, () => - this.ensureBinary({ - name: 'ffmpeg', - destinationPath: targetPath, - downloadUrl: url, - expectedSha256: async () => { - try { - const checksumText = await downloadText(checksumUrl, signal); - const firstToken = checksumText.trim().split(/\s+/)[0]; - return /^[a-fA-F0-9]{64}$/.test(firstToken) ? firstToken.toLowerCase() : null; - } catch { - return null; - } - }, - onStatus: opts.onStatus, - onDownloadProgress: makeDownloadProgress('ffmpeg', source, onProgress), - requiredChecksum: false, - signal - }) - ); - return downloadOk ? targetPath : null; - }, - { kind: 'managed', channel: 'default', url: BINARY_SOURCES.ffmpegStatic.download }, - opts - ); + const bundled = bundledBinaryPath(id); + const source: DependencySource = { kind: 'bundled', path: bundled }; + const diag = await this.probeAndAccept(id, source, bundled, attempts, onProgress, signal); + if (diag) return diag; - const ffprobeDiag = await this.resolveSingleBinary( - 'ffprobe', - overrides?.ffprobe, - process.env.ARROXY_FFPROBE_PATH, - 'ARROXY_FFPROBE_PATH', - async (source, attempts) => { - const asset = ffprobeAsset(); - if (!asset) return null; - const targetPath = path.join(this.cacheDir, ffprobeExecutableName()); - // Skip the network round-trip if the file already exists. ensureBinary - // does this for the simple-download path; ensureZippedBinary/TarXzBinary - // do not, so we mirror it here. - if (await this.isUsableBinary(targetPath)) return targetPath; - const url = ffprobeDownloadUrl(asset); - const downloadOk = await this.tryManagedDownload('ffprobe', attempts, source, onProgress, () => { - if (asset.format === 'zip') { - return this.ensureZippedBinary({ - name: 'ffprobe', - downloadUrl: url, - zipFileName: asset.source === 'evermeet' ? 'ffprobe.zip' : asset.archive, - innerExecutableName: ffprobeExecutableName(), - destinationPath: targetPath, - expectedSha256: () => Promise.resolve(null), - onStatus: opts.onStatus, - onDownloadProgress: makeDownloadProgress('ffprobe', source, onProgress), - signal - }); - } - return this.ensureTarXzBinary({ - name: 'ffprobe', - downloadUrl: url, - archiveFileName: asset.archive, - innerExecutableName: ffprobeExecutableName(), - destinationPath: targetPath, - onStatus: opts.onStatus, - onDownloadProgress: makeDownloadProgress('ffprobe', source, onProgress), - signal - }); - }); - return downloadOk ? targetPath : null; - }, - { kind: 'managed', channel: 'default', url: 'ffprobe-managed' }, - opts - ); + const failed = failedDiagnostic(id, attempts); + this.lastDiagnostics[id] = failed; + return failed; + }; - return { ffmpeg: ffmpegDiag, ffprobe: ffprobeDiag }; + const [ffmpeg, ffprobe] = await Promise.all([resolveOne('ffmpeg', overrides?.ffmpeg, 'ARROXY_FFMPEG_PATH'), resolveOne('ffprobe', overrides?.ffprobe, 'ARROXY_FFPROBE_PATH')]); + return { ffmpeg, ffprobe }; } // Single-binary resolve helper: manual override → env override → managed - // download. Used by resolveDeno and the non-Windows ffmpeg/ffprobe paths. + // download. Used by resolveDeno. private async resolveSingleBinary(id: DependencyId, manualPath: string | undefined, envPath: string | undefined, envVar: string, doManaged: (source: DependencySource, attempts: DependencyAttempt[]) => Promise, managedSource: DependencySource, opts: ResolveOptions): Promise { const attempts: DependencyAttempt[] = []; const onProgress = opts.onProgress; @@ -968,52 +782,6 @@ export class BinaryManager { return pair.ffprobe.resolvedPath; } - // Linux BtbN ffmpeg builds ship as .tar.xz, which Node has no built-in - // extractor for. We shell out to system `tar` (always present on Linux/ - // macOS, ships with Win10 1803+ but we use zip on Windows). xz support - // in `tar` is provided by xz-utils, also ubiquitous on modern distros. - private async ensureTarXzBinary(config: { name: string; downloadUrl: string; archiveFileName: string; innerExecutableName: string; destinationPath: string; onStatus?: StatusReporter; onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal }): Promise { - const { destinationPath, name, onStatus, onDownloadProgress, signal } = config; - - const existing = this.inProgress.get(destinationPath); - if (existing) return existing; - - const promise = (async (): Promise => { - const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), `arroxy-${name}-`)); - const archivePath = path.join(tempDir, config.archiveFileName); - - try { - onStatus?.('downloadingBinary', { name }); - logger.info(`Downloading ${name}`, { - downloadUrl: config.downloadUrl, - destinationPath - }); - - await downloadFile(config.downloadUrl, archivePath, onDownloadProgress, true, signal); - - const extractDir = path.join(tempDir, 'unpacked'); - await fsPromises.mkdir(extractDir, { recursive: true }); - await execFileAsync('tar', ['-xJf', archivePath, '-C', extractDir], { signal }); - - const innerPath = await this.findExecutableInTree(extractDir, config.innerExecutableName); - if (!innerPath) { - throw new Error(`${name} archive did not contain ${config.innerExecutableName}`); - } - - await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true }); - await fsPromises.copyFile(innerPath, destinationPath); - await fsPromises.chmod(destinationPath, 0o755); - } finally { - await fsPromises.rm(tempDir, { recursive: true, force: true }); - } - })().finally(() => { - this.inProgress.delete(destinationPath); - }); - - this.inProgress.set(destinationPath, promise); - return promise; - } - // Deno is the JS runtime yt-dlp uses for nsig/signature decoding on the web // client. Without it, yt-dlp silently drops every JS-needing client and // falls back to android_vr — which our PoT (bound to web.gvs) can't help. @@ -1058,13 +826,7 @@ export class BinaryManager { expectedSha256: async () => { try { const checksumText = await downloadText(checksumUrl, signal); - return ( - parseShaLine(checksumText, assetName) ?? - (() => { - const firstToken = checksumText.trim().split(/\s+/)[0]; - return /^[a-fA-F0-9]{64}$/.test(firstToken) ? firstToken.toLowerCase() : null; - })() - ); + return parseShaLine(checksumText, assetName) ?? parseStandaloneSha256(checksumText) ?? parsePowerShellFileHash(checksumText); } catch { return null; } @@ -1086,7 +848,7 @@ export class BinaryManager { return diag.resolvedPath; } - private async ensureZippedBinary(config: { name: string; downloadUrl: string; zipFileName: string; innerExecutableName: string; destinationPath: string; expectedSha256: () => Promise; onStatus?: StatusReporter; onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal }): Promise { + private async ensureZippedBinary(config: { name: string; downloadUrl: string; zipFileName: string; innerExecutableName: string; destinationPath: string; expectedSha256: () => Promise; requiredChecksum?: boolean; onStatus?: StatusReporter; onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal }): Promise { const { destinationPath, name, onStatus, onDownloadProgress, signal } = config; const existing = this.inProgress.get(destinationPath); @@ -1111,6 +873,8 @@ export class BinaryManager { if (actual !== expected) { throw new Error(`${name} checksum mismatch. Expected ${expected.slice(0, 8)}..., got ${actual.slice(0, 8)}...`); } + } else if (config.requiredChecksum) { + throw new Error(`Checksum source unavailable for ${name}. Refusing to use unverified archive.`); } else { logger.warn(`Checksum unavailable for ${name}, proceeding without verification`); } @@ -1141,66 +905,14 @@ export class BinaryManager { return promise; } - // Variant of ensureZippedBinary that extracts multiple members from a single - // archive into distinct destinations. Used for the Windows ffmpeg+ffprobe - // pair so both binaries come from the exact same Gyan build. - private async ensureZippedBinaryMulti(config: { name: string; downloadUrl: string; zipFileName: string; members: { innerName: string; destinationPath: string }[]; expectedSha256: () => Promise; onStatus?: StatusReporter; onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal }): Promise { - const { name, members, onStatus, onDownloadProgress, signal } = config; - const lockKey = members[0]?.destinationPath ?? name; - - const existing = this.inProgress.get(lockKey); - if (existing) return existing; - - const promise = (async (): Promise => { - const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), `arroxy-${name}-`)); - const zipPath = path.join(tempDir, config.zipFileName); - - try { - onStatus?.('downloadingBinary', { name }); - logger.info(`Downloading ${name}`, { downloadUrl: config.downloadUrl }); - - await downloadFile(config.downloadUrl, zipPath, onDownloadProgress, true, signal); - - const expected = await config.expectedSha256(); - if (expected) { - const actual = await sha256ForFile(zipPath); - if (actual !== expected) { - throw new Error(`${name} checksum mismatch. Expected ${expected.slice(0, 8)}..., got ${actual.slice(0, 8)}...`); - } - } - - const extractDir = path.join(tempDir, 'unpacked'); - await fsPromises.mkdir(extractDir, { recursive: true }); - await extractZip(zipPath, { dir: extractDir }); - - for (const member of members) { - const innerPath = await this.findExecutableInTree(extractDir, member.innerName); - if (!innerPath) { - throw new Error(`${name} archive did not contain ${member.innerName}`); - } - await fsPromises.mkdir(path.dirname(member.destinationPath), { recursive: true }); - await fsPromises.copyFile(innerPath, member.destinationPath); - if (process.platform !== 'win32') { - await fsPromises.chmod(member.destinationPath, 0o755); - } - } - } finally { - await fsPromises.rm(tempDir, { recursive: true, force: true }); - } - })().finally(() => { - this.inProgress.delete(lockKey); - }); - - this.inProgress.set(lockKey, promise); - return promise; - } - - private async findExecutableInTree(root: string, name: string): Promise { + private async findExecutableInTree(root: string, name: string, depth = 0): Promise { + if (depth > ARCHIVE_TREE_MAX_DEPTH) return null; const entries = await fsPromises.readdir(root, { withFileTypes: true }); for (const entry of entries) { + if (entry.isSymbolicLink()) continue; const full = path.join(root, entry.name); if (entry.isDirectory()) { - const nested = await this.findExecutableInTree(full, name); + const nested = await this.findExecutableInTree(full, name, depth + 1); if (nested) return nested; } else if (entry.isFile() && entry.name === name) { return full; @@ -1234,7 +946,7 @@ export class BinaryManager { private async downloadBinary(config: EnsureBinaryConfig): Promise { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (config.signal?.aborted) throw new Error('Cancelled'); + if (config.signal?.aborted) throw cancelError(); try { await this.attemptDownload(config); return; @@ -1370,14 +1082,17 @@ export class BinaryManager { export const binaryInternals = { parseShaLine, + parseStandaloneSha256, + parsePowerShellFileHash, parseContentRangeStart, resolvePartialResponseMode, ytDlpAssetName, - ffmpegAssetName, denoAssetName, denoAssetTarget, denoExecutableName, sha256ForFile, classifyProbeError, - whereOnPath + classifyDownloadError, + whereOnPath, + bundledBinaryPath }; diff --git a/src/main/services/WarmupService.ts b/src/main/services/WarmupService.ts index 3c071877..f7966898 100644 --- a/src/main/services/WarmupService.ts +++ b/src/main/services/WarmupService.ts @@ -10,10 +10,11 @@ import type { TokenService } from './TokenService'; const logger = log.scope('warmup'); // Cap any single binary resolve at this. Unbounded `got` retries on a slow -// CDN can otherwise hang the splash for ~30 minutes — the user has no out -// without a Cancel button. With Cancel + this cap, worst-case wait is ~90s -// per binary before the failure surfaces and the repair UI takes over. -const PER_BINARY_BUDGET_MS = 90_000; +// CDN can otherwise hang the splash for a very long time — the user has no +// out without a Cancel button. We allow a long budget because large Windows +// ffmpeg archives can take several minutes on slow links, and aborting a +// still-progressing transfer at 90s proved too aggressive in production. +const PER_BINARY_BUDGET_MS = 30 * 60 * 1000; // `got` fires `downloadProgress` per network chunk — hundreds of events per // second on a fast pipe. Without throttling, the IPC fire-hose plus per-event diff --git a/src/main/services/analytics.ts b/src/main/services/analytics.ts index 6805c48e..502185b2 100644 --- a/src/main/services/analytics.ts +++ b/src/main/services/analytics.ts @@ -1,4 +1,4 @@ -import { initialize as aptabaseInit, trackEvent } from '@aptabase/electron/main'; +import { OpenPanel } from '@openpanel/sdk'; type Props = Record; type CrashReason = 'clean-exit' | 'abnormal-exit' | 'killed' | 'crashed' | 'oom' | 'launch-failed' | 'integrity-failure' | 'memory-eviction'; @@ -19,9 +19,18 @@ type CrashDetectedInput = serviceName?: string; }; -// Allowlist: event name → permitted prop keys. -// Any call with an unknown event or disallowed key throws in dev and silently -// drops in prod, preventing accidental URL/path/title leakage. +export interface DeviceInfo { + appVersion: string; + platform: NodeJS.Platform; + architecture: string; + systemVersion: string; + modelName: string; + // Raw OS/Electron-detected locale (e.g. `app.getLocale()`). + osLocale: string; + // User's in-app language override from Settings; falls back to OS locale. + appLocale: string; +} + const ALLOWED: Record = { app_started: ['install_channel', 'platform_arch', 'is_first_run'], update_available: ['to_version', 'install_channel'], @@ -30,38 +39,75 @@ const ALLOWED: Record = { download_started: ['preset', 'has_subtitles', 'has_sponsorblock', 'cookies_enabled', 'embed_metadata', 'embed_thumbnail'], download_finished: ['outcome', 'duration_bucket', 'size_bucket', 'error_category'], tray_close_chosen: ['choice', 'remember'], - binary_setup_failed: ['binary', 'phase'], + binary_setup_failed: ['binary', 'phase', 'code'], crash_detected: ['type', 'reason'], wizard_started: [] }; const MAX_STR = 32; +function mapOperatingSystem(platform: NodeJS.Platform): string { + if (platform === 'darwin') return 'macOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'linux') return 'Linux'; + return platform; +} + +function buildDefaultPayload(info: DeviceInfo): Record { + const segs = info.systemVersion.split('.'); + const major = segs[0] ?? ''; + const majorMinor = segs.length >= 2 ? `${segs[0]}.${segs[1]}` : major; + return { + app_version: info.appVersion, + build_number: info.appVersion, + platform: info.platform, + operating_system: mapOperatingSystem(info.platform), + system_version: info.systemVersion, + major_system_version: major, + major_minor_system_version: majorMinor, + architecture: info.architecture, + model_name: info.modelName.slice(0, 64), + os_locale: info.osLocale, + app_locale: info.appLocale, + sdk_client_version: `arroxy/${info.appVersion}` + }; +} + let _dev = false; -let _started = false; +let _op: OpenPanel | null = null; let _on = false; const _seenCrashSignatures = new Set(); -// Must be called synchronously before app.isReady() so aptabase can register -// its custom protocol scheme before Electron locks the scheme registry. +// Initialize OpenPanel. Plain HTTPS POST — safe to call from app.whenReady() +// after settings load. // // In dev we stay fully offline by default — HMR reloads would otherwise spam // `wizard_started` / `app_started` and pollute prod stats. Set -// ARROXY_ANALYTICS_DEBUG=1 to opt in; events will be tagged debug-mode by the -// Aptabase SDK (app.isPackaged === false) so they're filterable on the dashboard. -export function setupAnalytics(appKey: string | undefined, isDev: boolean): void { +// ARROXY_ANALYTICS_DEBUG=1 to opt in. +export function setupAnalytics(clientId: string | undefined, clientSecret: string | undefined, isDev: boolean, installId: string, deviceInfo?: DeviceInfo): void { _dev = isDev; - _started = false; + _op = null; _on = false; _seenCrashSignatures.clear(); - if (!appKey) return; + if (!clientId || !clientSecret) return; const debugOptIn = process.env.ARROXY_ANALYTICS_DEBUG === '1'; if (isDev && !debugOptIn) return; - void aptabaseInit(appKey); - _started = true; + _op = new OpenPanel({ + clientId, + clientSecret, + // Runtime gate via filter — `disabled` queues instead of dropping, which + // isn't what we want when the user opts out. + filter: () => _on + }); + if (deviceInfo) { + const payload = buildDefaultPayload(deviceInfo); + _op.setGlobalProperties(payload); + void _op.identify({ profileId: installId, properties: payload }); + } else { + void _op.identify({ profileId: installId }); + } } -// Called after settings are loaded inside app.whenReady(). export function setAnalyticsEnabled(enabled: boolean): void { _on = enabled; } @@ -111,8 +157,8 @@ export function trackMain(name: string, props?: Props): boolean { } } } - if (!_started || !_on) return false; - void trackEvent(name, props); + if (!_op || !_on) return false; + void _op.track(name, props); return true; } diff --git a/src/main/stores/SettingsStore.ts b/src/main/stores/SettingsStore.ts index f7da2850..5513b197 100644 --- a/src/main/stores/SettingsStore.ts +++ b/src/main/stores/SettingsStore.ts @@ -1,10 +1,11 @@ +import { randomUUID } from 'node:crypto'; import Store from 'electron-store'; import type { AppSettings, CommonSettings, PlaylistPrefs, SinglePrefs } from '@shared/types'; import type { SettingsPatch } from '@shared/api'; export type { SettingsPatch }; -const COMMON_FLAT_KEYS = ['defaultOutputDir', 'rememberLastOutputDir', 'uiZoom', 'uiTheme', 'language', 'commonPaths', 'cookiesPath', 'cookiesEnabled', 'proxyUrl', 'clipboardWatchEnabled', 'closeBehavior', 'embedChapters', 'embedMetadata', 'embedThumbnail', 'writeDescription', 'writeThumbnail', 'lastSponsorBlockMode', 'lastSponsorBlockCategories', 'analyticsEnabled', 'firstRunCompleted', 'drawerOpen'] as const; +const COMMON_FLAT_KEYS = ['defaultOutputDir', 'rememberLastOutputDir', 'uiZoom', 'uiTheme', 'language', 'commonPaths', 'cookiesPath', 'cookiesEnabled', 'proxyUrl', 'clipboardWatchEnabled', 'closeBehavior', 'embedChapters', 'embedMetadata', 'embedThumbnail', 'writeDescription', 'writeThumbnail', 'lastSponsorBlockMode', 'lastSponsorBlockCategories', 'analyticsEnabled', 'firstRunCompleted', 'drawerOpen', 'installId'] as const; const SINGLE_FLAT_KEYS = ['lastPreset', 'lastVideoResolution', 'lastSubtitleLanguages', 'lastSubtitleMode', 'lastSubtitleFormat', 'lastSubfolderEnabled', 'lastSubfolder'] as const; @@ -60,6 +61,18 @@ export class SettingsStore { this.store = new Store({ name: 'settings', cwd: userDataPath, defaults, clearInvalidConfig: true }); this.defaults = defaults; this.maybeMigrate(); + this.ensureInstallId(); + } + + // Guarantee a per-install UUID for telemetry (TelemetryDeck `clientUser`). + // electron-store's `defaults` is shallow-merged at the top level, so an + // existing user whose on-disk `common` predates this field would never + // receive the default. Stamp lazily here after migration. + private ensureInstallId(): void { + const current = this.store.store; + if (current.common.installId) return; + const next: AppSettings = { ...current, common: { ...current.common, installId: randomUUID() } }; + this.store.set(next); } private maybeMigrate(): void { diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index 6c6eeef5..ac8a6347 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,23 +1,35 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import path from 'node:path'; -export function spawnYtDlp(binaryPath: string, args: string[], ffmpegPath: string | null): ChildProcessWithoutNullStreams { +// Linux: BtbN's shared ffmpeg build expects libav*.so.* siblings in +// the executable's own directory (or LD_LIBRARY_PATH). The binary has +// no rpath set, so we inject LD_LIBRARY_PATH at spawn time. Harmless on +// non-Linux (DYLD_LIBRARY_PATH is SIP-blocked on macOS, and Win uses +// native exe-dir DLL search). +function envWithFfmpegPaths(ffmpegPath: string | null): NodeJS.ProcessEnv { const env = { ...process.env }; - - if (ffmpegPath) { - const ffmpegDir = path.dirname(ffmpegPath); - env.PATH = ffmpegDir + path.delimiter + (env.PATH ?? ''); + if (!ffmpegPath) return env; + const ffmpegDir = path.dirname(ffmpegPath); + env.PATH = ffmpegDir + path.delimiter + (env.PATH ?? ''); + if (process.platform === 'linux') { + env.LD_LIBRARY_PATH = ffmpegDir + path.delimiter + (env.LD_LIBRARY_PATH ?? ''); } + return env; +} +export function spawnYtDlp(binaryPath: string, args: string[], ffmpegPath: string | null): ChildProcessWithoutNullStreams { return spawn(binaryPath, args, { - env, + env: envWithFfmpegPaths(ffmpegPath), windowsHide: true, detached: process.platform !== 'win32' }); } export function spawnFFmpeg(binaryPath: string, args: string[]): ChildProcessWithoutNullStreams { - return spawn(binaryPath, args, { windowsHide: true }); + return spawn(binaryPath, args, { + env: envWithFfmpegPaths(binaryPath), + windowsHide: true + }); } export function splitStderrLines(text: string): string[] { diff --git a/src/renderer/src/components/system/SplashScreen.tsx b/src/renderer/src/components/system/SplashScreen.tsx index 0a66e5db..491b9f00 100644 --- a/src/renderer/src/components/system/SplashScreen.tsx +++ b/src/renderer/src/components/system/SplashScreen.tsx @@ -48,7 +48,7 @@ export function SplashScreen({ initialized, warmupBlocking, warmupDiagnostics, w return (
{ if (fading) setGone(true); }} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7c8ba38e..2806c957 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -33,6 +33,11 @@ export const DEFAULTS: { // Single factory for the AppSettings shape — main process, tests, and // browserMock all build from here. Adding a new field to AppSettings forces // every caller to supply or ignore it explicitly. +// +// `installId` is intentionally omitted — it's stamped lazily by SettingsStore +// on first launch (Node-only, depends on `node:crypto`). Keeping it out of +// this factory avoids pulling Node modules into renderer/test bundles that +// import the defaults helper. export function defaultAppSettings(downloadsDir: string): AppSettings { return { common: { diff --git a/src/shared/types.ts b/src/shared/types.ts index 27ddb0e6..f13af337 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,6 +28,10 @@ export interface AppError { export interface CommonSettings { defaultOutputDir: string; rememberLastOutputDir: boolean; + // Stable per-install random UUID used as the OpenPanel `profileId`. No PII — + // this is a random anonymous identifier, not derived from the user. Generated + // lazily by SettingsStore on first launch when missing. + installId?: string; uiZoom?: number; uiTheme?: UiTheme; language?: SupportedLang; @@ -199,7 +203,7 @@ export type DependencyId = (typeof DEPENDENCY_IDS)[number]; export const BLOCKING_DEPENDENCY_IDS: readonly DependencyId[] = ['yt-dlp', 'ffmpeg', 'ffprobe'] as const; -export type DependencySource = { kind: 'manualOverride'; path: string } | { kind: 'envOverride'; path: string; envVar: string } | { kind: 'managed'; channel: 'nightly' | 'stable' | 'default'; url: string } | { kind: 'systemPath'; path: string }; +export type DependencySource = { kind: 'manualOverride'; path: string } | { kind: 'envOverride'; path: string; envVar: string } | { kind: 'managed'; channel: 'nightly' | 'stable' | 'default'; url: string } | { kind: 'systemPath'; path: string } | { kind: 'cache'; path: string } | { kind: 'bundled'; path: string }; export type DependencyFailureKind = 'download_failed' | 'extract_failed' | 'hash_failed' | 'spawn_failed' | 'permission_denied' | 'blocked_or_quarantined' | 'bad_exit_code' | 'timeout' | 'pair_incomplete'; diff --git a/tests/__mocks__/aptabase-main.ts b/tests/__mocks__/aptabase-main.ts deleted file mode 100644 index de03864b..00000000 --- a/tests/__mocks__/aptabase-main.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vi } from 'vitest'; -export const initialize = vi.fn().mockResolvedValue(undefined); -export const trackEvent = vi.fn().mockResolvedValue(undefined); diff --git a/tests/__mocks__/electron.ts b/tests/__mocks__/electron.ts new file mode 100644 index 00000000..ef6814bc --- /dev/null +++ b/tests/__mocks__/electron.ts @@ -0,0 +1,5 @@ +// Minimal stub of `electron` for unit tests. Only `app.isPackaged` is read by +// BinaryManager (bundledBinaryPath dev-vs-prod branch). +export const app = { + isPackaged: false +}; diff --git a/tests/unit/analytics-allowlist.test.ts b/tests/unit/analytics-allowlist.test.ts index 83df13dd..560ee591 100644 --- a/tests/unit/analytics-allowlist.test.ts +++ b/tests/unit/analytics-allowlist.test.ts @@ -1,12 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('@aptabase/electron/main', () => ({ - initialize: vi.fn().mockResolvedValue(undefined), - trackEvent: vi.fn().mockResolvedValue(undefined) -})); +const { ctorMock, trackMock, identifyMock, setGlobalPropertiesMock } = vi.hoisted(() => { + const trackMock = vi.fn().mockResolvedValue(undefined); + const identifyMock = vi.fn().mockResolvedValue(undefined); + const setGlobalPropertiesMock = vi.fn(); + const ctorMock = vi.fn().mockImplementation(function () { + return { track: trackMock, identify: identifyMock, setGlobalProperties: setGlobalPropertiesMock }; + }); + return { ctorMock, trackMock, identifyMock, setGlobalPropertiesMock }; +}); + +vi.mock('@openpanel/sdk', () => ({ OpenPanel: ctorMock })); import { setupAnalytics, setAnalyticsEnabled, trackMain, probeDurationBucket, downloadDurationBucket, sizeBucket } from '@main/services/analytics'; -import { initialize as aptabaseInit, trackEvent } from '@aptabase/electron/main'; beforeEach(() => { vi.clearAllMocks(); @@ -14,72 +20,169 @@ beforeEach(() => { }); describe('allowlist validation', () => { - it('(a) throws for unknown event names in dev mode', () => { - setupAnalytics(undefined, true); + it('throws for unknown event names in dev mode', () => { + setupAnalytics(undefined, undefined, true, 'install-id-test'); expect(() => trackMain('not_a_real_event')).toThrow('[analytics] unknown event: "not_a_real_event"'); }); it('throws for disallowed prop key in dev mode', () => { - setupAnalytics(undefined, true); + setupAnalytics(undefined, undefined, true, 'install-id-test'); expect(() => trackMain('app_started', { install_channel: 'direct', url: 'http://evil.com' } as any)).toThrow(/prop "url" not allowed/); }); - it('(b) throws for overlong prop string values in dev mode', () => { - setupAnalytics(undefined, true); + it('throws for overlong prop string values in dev mode', () => { + setupAnalytics(undefined, undefined, true, 'install-id-test'); expect(() => trackMain('app_started', { install_channel: 'x'.repeat(33) })).toThrow(/too long/); }); it('silently drops unknown event names in prod mode (no app key)', () => { - setupAnalytics(undefined, false); + setupAnalytics(undefined, undefined, false, 'install-id-test'); expect(() => trackMain('totally_fake_event')).not.toThrow(); }); it('silently drops disallowed prop key in prod mode', () => { - setupAnalytics(undefined, false); + setupAnalytics(undefined, undefined, false, 'install-id-test'); expect(() => trackMain('app_started', { install_channel: 'direct', secret_url: 'http://evil.com' } as any)).not.toThrow(); }); + + it('accepts stable failure code on binary_setup_failed', () => { + setupAnalytics(undefined, undefined, true, 'install-id-test'); + expect(() => trackMain('binary_setup_failed', { binary: 'ffmpeg', phase: 'download_failed', code: 'ARX-001' })).not.toThrow(); + }); +}); + +describe('OpenPanel track delegation', () => { + it('emits a single op.track call per event (no companion error events)', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); + setAnalyticsEnabled(true); + trackMain('binary_setup_failed', { binary: 'ytdlp', phase: 'download_failed', code: 'ARX-001' }); + expect(trackMock).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledWith('binary_setup_failed', { binary: 'ytdlp', phase: 'download_failed', code: 'ARX-001' }); + }); + + it('emits download_finished as a single event regardless of outcome', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); + setAnalyticsEnabled(true); + trackMain('download_finished', { outcome: 'success', duration_bucket: '<30s', size_bucket: '<50MB' }); + expect(trackMock).toHaveBeenCalledTimes(1); + trackMock.mockClear(); + trackMain('download_finished', { outcome: 'error', error_category: 'disk_full' }); + expect(trackMock).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledWith('download_finished', { outcome: 'error', error_category: 'disk_full' }); + }); + + it('emits app_started normally', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); + setAnalyticsEnabled(true); + trackMain('app_started', { install_channel: 'direct', platform_arch: 'linux-x64', is_first_run: false }); + expect(trackMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('identify + global properties', () => { + it('calls identify with profileId=installId and global properties when deviceInfo is provided', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test', { + appVersion: '1.2.3', + platform: 'linux', + architecture: 'x64', + systemVersion: '6.8.0-111-generic', + modelName: 'Intel(R) Core(TM) i7-12700H', + osLocale: 'en-US', + appLocale: 'es' + }); + expect(identifyMock).toHaveBeenCalledTimes(1); + expect(identifyMock).toHaveBeenCalledWith({ + profileId: 'install-id-test', + properties: expect.objectContaining({ + app_version: '1.2.3', + build_number: '1.2.3', + platform: 'linux', + operating_system: 'Linux', + system_version: '6.8.0-111-generic', + major_system_version: '6', + major_minor_system_version: '6.8', + architecture: 'x64', + model_name: 'Intel(R) Core(TM) i7-12700H', + os_locale: 'en-US', + app_locale: 'es', + sdk_client_version: 'arroxy/1.2.3' + }) + }); + expect(setGlobalPropertiesMock).toHaveBeenCalledTimes(1); + }); + + it('truncates model_name to 64 chars', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test', { + appVersion: '1.0.0', + platform: 'darwin', + architecture: 'arm64', + systemVersion: '23.5.0', + modelName: 'X'.repeat(120), + osLocale: 'en-US', + appLocale: 'en-US' + }); + const props = setGlobalPropertiesMock.mock.calls[0][0]; + expect(props.model_name.length).toBe(64); + }); + + it('still calls identify (no properties) when deviceInfo is omitted', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); + expect(identifyMock).toHaveBeenCalledWith({ profileId: 'install-id-test' }); + expect(setGlobalPropertiesMock).not.toHaveBeenCalled(); + }); }); -describe('(c) analyticsEnabled=false short-circuits', () => { - it('does not call trackEvent when disabled', async () => { - setupAnalytics('A-EU-test123456', false); +describe('analyticsEnabled=false short-circuits', () => { + it('does not call track when disabled', () => { + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(false); trackMain('app_started', { install_channel: 'direct', platform_arch: 'linux-x64', is_first_run: false }); - expect(trackEvent).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalled(); }); - it('does not call trackEvent when no app key (not started)', () => { - setupAnalytics(undefined, false); + it('does not call track when no credentials (not started)', () => { + setupAnalytics(undefined, undefined, false, 'install-id-test'); setAnalyticsEnabled(true); trackMain('app_started', { install_channel: 'direct', platform_arch: 'linux-x64', is_first_run: false }); - expect(trackEvent).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalled(); + }); + + it('does not call track when only clientId is provided (missing secret)', () => { + setupAnalytics('client-id', undefined, false, 'install-id-test'); + setAnalyticsEnabled(true); + trackMain('app_started', { install_channel: 'direct', platform_arch: 'linux-x64', is_first_run: false }); + expect(ctorMock).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalled(); }); }); describe('dev-mode debug opt-in', () => { - it('does NOT initialize aptabase in dev by default', () => { - setupAnalytics('A-EU-test123456', true); - expect(aptabaseInit).not.toHaveBeenCalled(); + it('does NOT initialize OpenPanel in dev by default', () => { + setupAnalytics('client-id', 'client-secret', true, 'install-id-test'); + expect(ctorMock).not.toHaveBeenCalled(); }); - it('initializes aptabase in dev when ARROXY_ANALYTICS_DEBUG=1', () => { + it('initializes OpenPanel in dev when ARROXY_ANALYTICS_DEBUG=1', () => { process.env.ARROXY_ANALYTICS_DEBUG = '1'; - setupAnalytics('A-EU-test123456', true); - expect(aptabaseInit).toHaveBeenCalledWith('A-EU-test123456'); + setupAnalytics('client-id', 'client-secret', true, 'install-id-test'); + expect(ctorMock).toHaveBeenCalledTimes(1); + const opts = ctorMock.mock.calls[0][0]; + expect(opts).toMatchObject({ clientId: 'client-id', clientSecret: 'client-secret' }); + expect(typeof opts.filter).toBe('function'); }); - it('still skips when no app key, even with debug flag', () => { + it('still skips when no credentials, even with debug flag', () => { process.env.ARROXY_ANALYTICS_DEBUG = '1'; - setupAnalytics(undefined, true); - expect(aptabaseInit).not.toHaveBeenCalled(); + setupAnalytics(undefined, undefined, true, 'install-id-test'); + expect(ctorMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/analytics-crash-dedupe.test.ts b/tests/unit/analytics-crash-dedupe.test.ts index 5f9a6261..846ddf12 100644 --- a/tests/unit/analytics-crash-dedupe.test.ts +++ b/tests/unit/analytics-crash-dedupe.test.ts @@ -1,12 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@aptabase/electron/main', () => ({ - initialize: vi.fn().mockResolvedValue(undefined), - trackEvent: vi.fn().mockResolvedValue(undefined) -})); +const { ctorMock, trackMock } = vi.hoisted(() => { + const trackMock = vi.fn().mockResolvedValue(undefined); + const identifyMock = vi.fn().mockResolvedValue(undefined); + const setGlobalPropertiesMock = vi.fn(); + const ctorMock = vi.fn().mockImplementation(function () { + return { track: trackMock, identify: identifyMock, setGlobalProperties: setGlobalPropertiesMock }; + }); + return { ctorMock, trackMock }; +}); + +vi.mock('@openpanel/sdk', () => ({ OpenPanel: ctorMock })); import { setAnalyticsEnabled, setupAnalytics, trackCrashDetectedOncePerSession } from '@main/services/analytics'; -import { trackEvent } from '@aptabase/electron/main'; beforeEach(() => { vi.clearAllMocks(); @@ -15,7 +21,7 @@ beforeEach(() => { describe('trackCrashDetectedOncePerSession', () => { it('emits a repeated identical child-process crash only once per session', () => { - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession({ @@ -31,15 +37,15 @@ describe('trackCrashDetectedOncePerSession', () => { name: 'Network Service' }); - expect(trackEvent).toHaveBeenCalledTimes(1); - expect(trackEvent).toHaveBeenCalledWith('crash_detected', { + expect(trackMock).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledWith('crash_detected', { type: 'Utility', reason: 'crashed' }); }); it('dedupes child-process crashes by type and reason even when names differ', () => { - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession({ @@ -55,11 +61,11 @@ describe('trackCrashDetectedOncePerSession', () => { name: 'Audio Service' }); - expect(trackEvent).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledTimes(1); }); it('dedupes renderer crashes by reason even when window roles differ', () => { - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession({ @@ -78,15 +84,15 @@ describe('trackCrashDetectedOncePerSession', () => { reason: 'crashed' }); - expect(trackEvent).toHaveBeenCalledTimes(1); - expect(trackEvent).toHaveBeenNthCalledWith(1, 'crash_detected', { + expect(trackMock).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledWith('crash_detected', { type: 'renderer', reason: 'crashed' }); }); it('does not poison the dedupe set while analytics is disabled or not started', () => { - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(false); const childCrash = { @@ -97,27 +103,27 @@ describe('trackCrashDetectedOncePerSession', () => { } as const; trackCrashDetectedOncePerSession(childCrash); - expect(trackEvent).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalled(); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession(childCrash); - expect(trackEvent).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledTimes(1); vi.clearAllMocks(); - setupAnalytics(undefined, false); + setupAnalytics(undefined, undefined, false, 'install-id-test'); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession(childCrash); - expect(trackEvent).not.toHaveBeenCalled(); + expect(trackMock).not.toHaveBeenCalled(); - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession(childCrash); - expect(trackEvent).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledTimes(1); }); it('clears the session dedupe state on fresh setupAnalytics()', () => { - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(true); const childCrash = { @@ -128,12 +134,12 @@ describe('trackCrashDetectedOncePerSession', () => { } as const; trackCrashDetectedOncePerSession(childCrash); - expect(trackEvent).toHaveBeenCalledTimes(1); + expect(trackMock).toHaveBeenCalledTimes(1); - setupAnalytics('A-EU-test123456', false); + setupAnalytics('client-id', 'client-secret', false, 'install-id-test'); setAnalyticsEnabled(true); trackCrashDetectedOncePerSession(childCrash); - expect(trackEvent).toHaveBeenCalledTimes(2); + expect(trackMock).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/unit/binary-manager-analytics.test.ts b/tests/unit/binary-manager-analytics.test.ts new file mode 100644 index 00000000..dfc59288 --- /dev/null +++ b/tests/unit/binary-manager-analytics.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; + +vi.mock('@main/services/analytics', () => ({ + trackMain: vi.fn() +})); + +import { BinaryManager } from '@main/services/BinaryManager'; +import { trackMain } from '@main/services/analytics'; +import type { DependencyAttempt, DependencySource } from '@shared/types'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('BinaryManager analytics', () => { + it('emits the stable ARX code for classified managed-download failures', async () => { + const mgr = new BinaryManager('/tmp/arroxy-binary-analytics'); + const attempts: DependencyAttempt[] = []; + const source: DependencySource = { + kind: 'managed', + channel: 'default', + url: 'https://example.com/ffmpeg.zip' + }; + + const ok = await ( + mgr as unknown as { + tryManagedDownload: (id: 'ffmpeg', attempts: DependencyAttempt[], source: DependencySource, onProgress: undefined, run: () => Promise) => Promise; + } + ).tryManagedDownload('ffmpeg', attempts, source, undefined, async () => { + throw new Error('checksum mismatch'); + }); + + expect(ok).toBe(false); + expect(trackMain).toHaveBeenCalledWith('binary_setup_failed', { + binary: 'ffmpeg', + phase: 'hash_failed', + code: 'ARX-003' + }); + }); + + it('classifies signal-driven managed-download aborts as timeout', async () => { + const mgr = new BinaryManager('/tmp/arroxy-binary-analytics'); + const attempts: DependencyAttempt[] = []; + const source: DependencySource = { + kind: 'managed', + channel: 'default', + url: 'https://example.com/ffmpeg.zip' + }; + + const ok = await ( + mgr as unknown as { + tryManagedDownload: (id: 'ffmpeg', attempts: DependencyAttempt[], source: DependencySource, onProgress: undefined, run: () => Promise) => Promise; + } + ).tryManagedDownload('ffmpeg', attempts, source, undefined, async () => { + throw new DOMException('Cancelled', 'AbortError'); + }); + + expect(ok).toBe(false); + expect(trackMain).toHaveBeenCalledWith('binary_setup_failed', { + binary: 'ffmpeg', + phase: 'timeout', + code: 'ARX-008' + }); + }); + + it('does not treat a benign "aborted by server" message as cancel', async () => { + const mgr = new BinaryManager('/tmp/arroxy-binary-analytics'); + const attempts: DependencyAttempt[] = []; + const source: DependencySource = { + kind: 'managed', + channel: 'default', + url: 'https://example.com/ffmpeg.zip' + }; + + const ok = await ( + mgr as unknown as { + tryManagedDownload: (id: 'ffmpeg', attempts: DependencyAttempt[], source: DependencySource, onProgress: undefined, run: () => Promise) => Promise; + } + ).tryManagedDownload('ffmpeg', attempts, source, undefined, async () => { + throw new Error('Request aborted by server during redirect'); + }); + + expect(ok).toBe(false); + expect(trackMain).toHaveBeenCalledWith('binary_setup_failed', { + binary: 'ffmpeg', + phase: 'download_failed', + code: 'ARX-001' + }); + }); +}); diff --git a/tests/unit/binary-manager-platform.test.ts b/tests/unit/binary-manager-platform.test.ts index 42875d00..5555ff32 100644 --- a/tests/unit/binary-manager-platform.test.ts +++ b/tests/unit/binary-manager-platform.test.ts @@ -25,9 +25,9 @@ describe('ytDlpAssetName', () => { expect(binaryInternals.ytDlpAssetName()).toBe('yt-dlp_macos'); }); - it('darwin x64 → yt-dlp_macos_legacy', () => { + it('darwin x64 → yt-dlp_macos (universal binary, _legacy was removed upstream)', () => { setPlatform('darwin', 'x64'); - expect(binaryInternals.ytDlpAssetName()).toBe('yt-dlp_macos_legacy'); + expect(binaryInternals.ytDlpAssetName()).toBe('yt-dlp_macos'); }); it('linux x64 → yt-dlp_linux', () => { @@ -41,43 +41,6 @@ describe('ytDlpAssetName', () => { }); }); -describe('ffmpegAssetName', () => { - it('win32 x64 → ffmpeg-win32-x64', () => { - setPlatform('win32', 'x64'); - expect(binaryInternals.ffmpegAssetName()).toBe('ffmpeg-win32-x64'); - }); - - it('win32 arm64 → ffmpeg-win32-arm64', () => { - setPlatform('win32', 'arm64'); - expect(binaryInternals.ffmpegAssetName()).toBe('ffmpeg-win32-arm64'); - }); - - it('darwin arm64 → ffmpeg-darwin-arm64', () => { - setPlatform('darwin', 'arm64'); - expect(binaryInternals.ffmpegAssetName()).toBe('ffmpeg-darwin-arm64'); - }); - - it('darwin x64 → ffmpeg-darwin-x64', () => { - setPlatform('darwin', 'x64'); - expect(binaryInternals.ffmpegAssetName()).toBe('ffmpeg-darwin-x64'); - }); - - it('linux x64 → ffmpeg-linux-x64', () => { - setPlatform('linux', 'x64'); - expect(binaryInternals.ffmpegAssetName()).toBe('ffmpeg-linux-x64'); - }); - - it('linux arm64 → ffmpeg-linux-arm64', () => { - setPlatform('linux', 'arm64'); - expect(binaryInternals.ffmpegAssetName()).toBe('ffmpeg-linux-arm64'); - }); - - it('unknown platform → null', () => { - setPlatform('freebsd', 'x64'); - expect(binaryInternals.ffmpegAssetName()).toBeNull(); - }); -}); - describe('denoAssetName', () => { it('win32 x64 → MSVC zip', () => { setPlatform('win32', 'x64'); diff --git a/tests/unit/binary-manager.test.ts b/tests/unit/binary-manager.test.ts index ee781cd8..ee95b585 100644 --- a/tests/unit/binary-manager.test.ts +++ b/tests/unit/binary-manager.test.ts @@ -34,4 +34,37 @@ describe('binaryInternals', () => { const digest = await binaryInternals.sha256ForFile(filePath); expect(digest).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'); }); + + it('parses the raw SHA-256 body from Gyan direct', () => { + const sha = binaryInternals.parseStandaloneSha256('6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541\n'); + + expect(sha).toBe('6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541'); + }); + + it('parses a labelled SHA-256 line if upstream changes the format', () => { + const sha = binaryInternals.parseStandaloneSha256('SHA256: 6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541\n'); + expect(sha).toBe('6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541'); + }); + + it('parses the " filename.zip" canonical Gyan body', () => { + const sha = binaryInternals.parseStandaloneSha256('6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541 ffmpeg-release-essentials.zip'); + expect(sha).toBe('6f58ce889f59c311410f7d2b18895b33c03456463486f3b1ebc93d97a0f54541'); + }); + + it('returns null when the body has no 64-hex token at all', () => { + expect(binaryInternals.parseStandaloneSha256('not a hash')).toBeNull(); + expect(binaryInternals.parseStandaloneSha256('')).toBeNull(); + }); + + it('parses the PowerShell Get-FileHash format used by deno Windows .sha256sum', () => { + const content = '\nAlgorithm : SHA256\nHash : 25F9871F5C1D9E999D60071F8069767134495FD601D2E2C7CE1E8C641487BDA0\nPath : C:\\a\\deno\\deno\\target\\release\\deno-x86_64-pc-windows-msvc.zip\n'; + const sha = binaryInternals.parsePowerShellFileHash(content); + + expect(sha).toBe('25f9871f5c1d9e999d60071f8069767134495fd601d2e2c7ce1e8c641487bda0'); + }); + + it('returns null for sha sources that lack a Hash line', () => { + expect(binaryInternals.parsePowerShellFileHash('Algorithm : SHA256\nHash : nothex\n')).toBeNull(); + expect(binaryInternals.parsePowerShellFileHash('')).toBeNull(); + }); }); diff --git a/tests/unit/warmup-service.test.ts b/tests/unit/warmup-service.test.ts index 1e04d063..bffcf8ab 100644 --- a/tests/unit/warmup-service.test.ts +++ b/tests/unit/warmup-service.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { WarmupService } from '@main/services/WarmupService'; import type { BinaryManager } from '@main/services/BinaryManager'; import type { TokenService } from '@main/services/TokenService'; @@ -29,6 +29,10 @@ function fakeBinaryManager(opts: { ytDlp: 'runnable' | 'failed'; ffmpeg: 'runnab const noopToken = { warmUp: vi.fn().mockResolvedValue(undefined) } as unknown as TokenService; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('WarmupService', () => { it('returns blockingFailures excluding deno', async () => { const bm = fakeBinaryManager({ ytDlp: 'runnable', ffmpeg: 'runnable', ffprobe: 'runnable', deno: 'failed' }); @@ -82,4 +86,17 @@ describe('WarmupService', () => { await a; expect((bm.resolveYtDlp as ReturnType).mock.calls.length).toBe(1); }); + + it('uses a 30 minute per-binary warmup budget', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation(() => new AbortController().signal); + const bm = fakeBinaryManager({ ytDlp: 'runnable', ffmpeg: 'runnable', ffprobe: 'runnable', deno: 'runnable' }); + const svc = new WarmupService({ binaryManager: bm, tokenService: noopToken }); + + await svc.run(); + + expect(timeoutSpy).toHaveBeenCalledTimes(3); + expect(timeoutSpy).toHaveBeenNthCalledWith(1, 1_800_000); + expect(timeoutSpy).toHaveBeenNthCalledWith(2, 1_800_000); + expect(timeoutSpy).toHaveBeenNthCalledWith(3, 1_800_000); + }); }); diff --git a/vitest.config.mts b/vitest.config.mts index 6dc8703f..745d2f2e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,8 +6,8 @@ const alias = { '@preload': path.resolve('src/preload'), '@renderer': path.resolve('src/renderer/src'), '@shared': path.resolve('src/shared'), - '@aptabase/electron/main': path.resolve('tests/__mocks__/aptabase-main.ts'), 'electron-log/main': path.resolve('tests/__mocks__/electron-log-main.ts'), + electron: path.resolve('tests/__mocks__/electron.ts') }; export default defineConfig({ From 10c5f1ed5cbcdb0b85d39daa1a9a75d441375eda Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 12:38:27 +0300 Subject: [PATCH 02/24] release: 0.3.1-beta.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6e0085b1..23ba83b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arroxy", - "version": "0.3.1-beta.2", + "version": "0.3.1-beta.3", "description": "Arroxy - YouTube downloader app", "main": "out/main/index.js", "author": { From a7dc354476e62d2fb027481bd78bb530b67b37ce Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 13:15:12 +0300 Subject: [PATCH 03/24] fix(packaging): return true from beforeBuild so asar gets node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ffmpeg/ffprobe-fetching beforeBuild hook returned undefined, which electron-builder treats as "node_modules handled externally" (packager.js — _nodeModulesHandledExternally). Result: every asar since 0.3.0 shipped zero node_modules, crashing every install at `Cannot find module 'electron-log/main'`. Also: route auto-update feed by semver prerelease tag so beta installs follow beta.yml instead of 404'ing on latest.yml. release: 0.3.1-beta.4 Co-Authored-By: Claude Opus 4.7 (1M context) --- build/beforeBuild.cjs | 5 ++ package.json | 4 +- scripts/release.sh | 92 +++++++++++++++++++++++++ src/main/ipc/registerUpdaterHandlers.ts | 33 ++++++++- 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100755 scripts/release.sh diff --git a/build/beforeBuild.cjs b/build/beforeBuild.cjs index acff4e96..e8844f14 100644 --- a/build/beforeBuild.cjs +++ b/build/beforeBuild.cjs @@ -15,4 +15,9 @@ exports.default = async function beforeBuild(context) { const script = path.join(cwd, 'scripts', 'build', 'fetch-embedded.sh'); console.log(`[beforeBuild] fetch ffmpeg/ffprobe for ${platform}-${archName}`); execFileSync('bash', [script, platform, archName], { stdio: 'inherit', cwd }); + // Returning falsy here makes electron-builder treat node_modules as + // "handled externally" and skip the prod-deps install/copy entirely + // (app-builder-lib/out/packager.js — _nodeModulesHandledExternally), + // which produces an asar with zero node_modules. + return true; }; diff --git a/package.json b/package.json index 23ba83b3..8e8a03cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arroxy", - "version": "0.3.1-beta.3", + "version": "0.3.1-beta.4", "description": "Arroxy - YouTube downloader app", "main": "out/main/index.js", "author": { @@ -40,6 +40,8 @@ "build:docs": "bun run build:readme && bun run build:landing && bun run build:blog", "smoke": "bun scripts/smoke-youtube.ts", "smoke:pot": "bun scripts/smoke-pot-runner.ts", + "release:beta": "bash scripts/release.sh beta", + "release:stable": "bash scripts/release.sh stable", "prepare": "husky" }, "dependencies": { diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..355aa743 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Tag + push a release. Refuses every common footgun: +# - wrong branch (beta must come from dev, stable from main) +# - dirty working tree +# - version/mode mismatch (beta tag w/o -, stable tag with -) +# - duplicate tag (local or remote) +# - lightweight tag (always uses -a, annotated) +# - forgotten --follow-tags +# +# Usage: +# scripts/release.sh beta # for v*-beta.N tags from dev +# scripts/release.sh stable # for v* tags from main +set -euo pipefail + +MODE="${1:?usage: $0 beta|stable}" + +case "$MODE" in + beta) EXPECTED_BRANCH=dev ;; + stable) EXPECTED_BRANCH=main ;; + *) echo "mode must be 'beta' or 'stable' (got '$MODE')" >&2; exit 1 ;; +esac + +# 1. branch check +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo '') +if [[ "$BRANCH" != "$EXPECTED_BRANCH" ]]; then + echo "ERR: $MODE release must be cut from '$EXPECTED_BRANCH' (currently on '$BRANCH')" >&2 + exit 1 +fi + +# 2. working tree must be clean +if [[ -n "$(git status --porcelain)" ]]; then + echo "ERR: working tree dirty. Commit or discard changes before tagging." >&2 + git status --short >&2 + exit 1 +fi + +# 3. branch must be in sync w/ origin (no unpushed commits, no commits behind) +git fetch --quiet origin "$BRANCH" 2>/dev/null || true +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse "origin/$BRANCH" 2>/dev/null || echo '') +if [[ -n "$REMOTE" && "$LOCAL" != "$REMOTE" ]]; then + AHEAD=$(git rev-list --count "origin/$BRANCH..HEAD") + BEHIND=$(git rev-list --count "HEAD..origin/$BRANCH") + if (( BEHIND > 0 )); then + echo "ERR: local '$BRANCH' is $BEHIND commit(s) behind origin/$BRANCH. Pull first." >&2 + exit 1 + fi + if (( AHEAD > 0 )); then + echo "Note: local '$BRANCH' is $AHEAD commit(s) ahead of origin/$BRANCH — git push --follow-tags will push them with the tag." + fi +fi + +# 4. read package.json version + validate shape matches mode +VERSION=$(node -p "require('./package.json').version") +case "$MODE" in + beta) + if [[ "$VERSION" != *-* ]]; then + echo "ERR: package.json version '$VERSION' has no '-' suffix — must be a pre-release semver (e.g. 0.4.0-beta.1) for beta mode" >&2 + exit 1 + fi + ;; + stable) + if [[ "$VERSION" == *-* ]]; then + echo "ERR: package.json version '$VERSION' contains '-' — strip the pre-release suffix for stable mode" >&2 + exit 1 + fi + ;; +esac + +TAG="v$VERSION" + +# 5. tag must not already exist locally or on remote +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "ERR: tag '$TAG' already exists locally. Bump package.json version or delete the tag." >&2 + exit 1 +fi +if git ls-remote --exit-code --tags origin "$TAG" >/dev/null 2>&1; then + echo "ERR: tag '$TAG' already on remote. Bump package.json version." >&2 + exit 1 +fi + +# 6. annotated tag + push branch + tag in one shot +echo "Tagging $TAG (annotated) on '$BRANCH'…" +git tag -a "$TAG" -m "release $VERSION" + +echo "Pushing branch + tag…" +git push --follow-tags + +echo +echo "✓ pushed $TAG" +echo " watch CI: gh run watch" +echo " release: https://github.com/antonio-orionus/Arroxy/releases/tag/$TAG" diff --git a/src/main/ipc/registerUpdaterHandlers.ts b/src/main/ipc/registerUpdaterHandlers.ts index a0f62824..c1ae9c18 100644 --- a/src/main/ipc/registerUpdaterHandlers.ts +++ b/src/main/ipc/registerUpdaterHandlers.ts @@ -12,6 +12,23 @@ import type { InstallChannel, UpdateAvailablePayload, UpdateInstallResult } from // still gets a banner so the user can copy the upgrade command. const NON_INSTALLABLE: ReadonlySet = new Set(['scoop', 'homebrew', 'portable']); +// Map the running version's semver prerelease tag to an electron-updater +// release channel. Stable versions follow `latest` and must never see beta +// releases; prerelease builds follow their own channel so a v0.3.1-beta.3 +// install upgrades along beta.yml, not latest.yml. +function resolveUpdateChannel(version: string): { channel: string; allowPrerelease: boolean } { + const dashIdx = version.indexOf('-'); + if (dashIdx === -1) return { channel: 'latest', allowPrerelease: false }; + const tag = version + .slice(dashIdx + 1) + .split('.', 1)[0] + .toLowerCase(); + if (tag === 'beta' || tag === 'alpha' || tag === 'rc') { + return { channel: tag, allowPrerelease: true }; + } + return { channel: 'latest', allowPrerelease: false }; +} + export function registerUpdaterHandlers(mainWindow: BrowserWindow): void { const installChannel = detectInstallChannel(app.getName()); @@ -25,7 +42,17 @@ export function registerUpdaterHandlers(mainWindow: BrowserWindow): void { // app-update.yml is absent from that extracted bundle, causing ENOENT when // electron-updater tries to read the feed. setFeedURL is authoritative and // overrides the missing file on all targets (no-op cost on NSIS/DMG/AppImage). - autoUpdater.setFeedURL({ provider: 'github', owner: 'antonio-orionus', repo: 'Arroxy' }); + // The channel must match the running version's semver tag so a beta install + // queries beta.yml (not latest.yml — which 404s on prerelease releases). + const { channel, allowPrerelease } = resolveUpdateChannel(app.getVersion()); + autoUpdater.setFeedURL({ + provider: 'github', + owner: 'antonio-orionus', + repo: 'Arroxy', + channel + }); + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = allowPrerelease; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; @@ -87,6 +114,8 @@ export function registerUpdaterHandlers(mainWindow: BrowserWindow): void { }); setTimeout(() => { - void autoUpdater.checkForUpdates(); + autoUpdater.checkForUpdates().catch((err: Error) => { + log.error('[updater] checkForUpdates failed', err.message); + }); }, 5_000); } From cb9900abdc307e8fb8a842609b92a7159c6bf4bb Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 13:38:35 +0300 Subject: [PATCH 04/24] fix(analytics): identify as web SDK so server mints deviceId/sessionId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default 'node' sdk name is treated as backend ingest by OpenPanel server — events arrive un-sessioned with empty deviceId/sessionId. Setting sdk='web' makes the server mint both. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/analytics.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/services/analytics.ts b/src/main/services/analytics.ts index 502185b2..c3ee83f9 100644 --- a/src/main/services/analytics.ts +++ b/src/main/services/analytics.ts @@ -95,6 +95,11 @@ export function setupAnalytics(clientId: string | undefined, clientSecret: strin _op = new OpenPanel({ clientId, clientSecret, + // Identify as a web/app SDK so the OpenPanel server mints deviceId + + // sessionId. The default `node` sdk name is treated as backend ingest — + // server returns empty deviceId/sessionId and events arrive un-sessioned. + sdk: 'web', + sdkVersion: '1.3.1', // Runtime gate via filter — `disabled` queues instead of dropping, which // isn't what we want when the user opts out. filter: () => _on From 0b6232f9c2d233911e90e75476a9805e4eb7f3d3 Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 13:39:41 +0300 Subject: [PATCH 05/24] ci(release): pre-create draft release to prevent multi-publisher race Parallel mac/linux build matrix + Windows Installer workflow each called electron-publisher-github's getOrCreateRelease(), which lists releases by tag and creates a draft when none is found. All racers saw no release simultaneously and each created one, leaving Linux assets (AppImage, tar.gz) stranded in an orphan draft and breaking build-flatpak's gh release download. Adds a prepare-release job between verify-version and build that idempotently creates a draft release for the tag. Subsequent publishers find and reuse it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21219e5a..28c31508 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,31 @@ jobs: fi echo "Releasing version $PACKAGE_VERSION" + prepare-release: + # Pre-create a single draft release for this tag before any publisher runs. + # Without this, the parallel mac/linux `build` matrix jobs each call + # electron-publisher-github's getOrCreateRelease(), which lists releases + # by tag and creates a draft when none is found. Both racers see no + # release simultaneously and create one each, plus the Windows Installer + # workflow creates a third — leaving Linux assets stranded in an orphan + # draft and breaking `build-flatpak`'s `gh release download`. + # + # By pre-creating a draft here, electron-publisher reuses it (it returns + # the existing draft on the next list-releases call) and Windows + # Installer's `view || create` shortcut also reuses it. + needs: verify-version + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Ensure single draft release exists for this tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --draft --title "$GITHUB_REF_NAME" --notes "" + build: # Windows is built separately by the Windows Installer workflow # (.github/workflows/installer-smoke.yml) so it can be smoke-tested @@ -34,7 +59,7 @@ jobs: # beforeBuild hook (build/beforeBuild.cjs invokes # scripts/build/fetch-embedded.sh). yt-dlp + deno remain runtime-fetched # by BinaryManager. - needs: verify-version + needs: prepare-release strategy: matrix: include: From d383e0abb8624ae95758810073ab48e705b800a6 Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 13:40:35 +0300 Subject: [PATCH 06/24] release: 0.3.1-beta.5 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8e8a03cd..44efbadd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arroxy", - "version": "0.3.1-beta.4", + "version": "0.3.1-beta.5", "description": "Arroxy - YouTube downloader app", "main": "out/main/index.js", "author": { From d5f7a9299cdbd8195cc41d7ecd7f69d3545e7590 Mon Sep 17 00:00:00 2001 From: Antonio Orionus Date: Thu, 7 May 2026 13:48:40 +0300 Subject: [PATCH 07/24] adding web analytics --- .env.example | 7 +++++- .github/workflows/ci.yml | 2 ++ docs/am/index.html | 11 ++++++++ docs/ar/index.html | 11 ++++++++ docs/blog/index.html | 11 ++++++++ .../video-downloader-comparison/index.html | 11 ++++++++ docs/bn/index.html | 11 ++++++++ docs/de/index.html | 11 ++++++++ docs/el/index.html | 11 ++++++++ docs/es/index.html | 11 ++++++++ docs/fr/index.html | 11 ++++++++ docs/hi/index.html | 11 ++++++++ docs/index.html | 11 ++++++++ docs/ja/index.html | 11 ++++++++ docs/om/index.html | 11 ++++++++ docs/ps/index.html | 11 ++++++++ docs/ru/index.html | 11 ++++++++ docs/sr/index.html | 11 ++++++++ docs/sw/index.html | 11 ++++++++ docs/uk/index.html | 11 ++++++++ docs/ur/index.html | 11 ++++++++ docs/uz/index.html | 11 ++++++++ docs/vi/index.html | 11 ++++++++ docs/zh/index.html | 11 ++++++++ landing-src/blog/build.mjs | 14 +++++++---- landing-src/blog/template-index.html | 1 + landing-src/blog/template-post.html | 1 + landing-src/build.mjs | 4 ++- landing-src/lib/render.mjs | 25 +++++++++++++++++++ landing-src/template.html | 1 + package.json | 6 ++--- 31 files changed, 293 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index c4bf14fa..6db0313b 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,12 @@ -# Analytics — OpenPanel (https://openpanel.dev) +# Analytics — OpenPanel (https://openpanel.dev) — desktop app OPENPANEL_CLIENT_ID= OPENPANEL_CLIENT_SECRET= +# Analytics — OpenPanel — landing site (docs/). Web SDK uses clientId only; +# secret kept here for parity / future server-side use, never embedded in HTML. +LANDING_OPENPANEL_CLIENT_ID= +LANDING_OPENPANEL_CLIENT_SECRET= + # Set to 1 to send analytics during `bun run dev`. Off by default — HMR # reloads would otherwise spam wizard_started / app_started. ARROXY_ANALYTICS_DEBUG=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e306bc1..33da4a5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,8 @@ jobs: run: bun install --frozen-lockfile - name: Regenerate landing + blog pages + env: + LANDING_OPENPANEL_CLIENT_ID: ${{ secrets.LANDING_OPENPANEL_CLIENT_ID }} run: bun run build:docs - name: Verify docs/ matches landing-src/ diff --git a/docs/am/index.html b/docs/am/index.html index 4e069499..32e3283e 100644 --- a/docs/am/index.html +++ b/docs/am/index.html @@ -74,6 +74,17 @@ + + +