diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 73ca39c..a49fa75 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,11 +6,6 @@ on: tags: ['v*'] pull_request: workflow_dispatch: - inputs: - native_client: - description: "Build the experimental libmpv native Windows client (artifact only, does not touch releases)" - type: boolean - default: false # A newer main push supersedes an older latest-image build. Release tags instead serialize and # remain retryable so one immutable version can never race itself or be cancelled halfway through. @@ -103,7 +98,7 @@ jobs: cache-to: type=gha,mode=max docker-main: - needs: [test, android] + needs: [test, android, windows-client] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: @@ -233,8 +228,10 @@ jobs: env: TAG: ${{ github.ref_name }} EXPECTED_CERT_SHA256: c0b1e2d90b443b07fe4ec4001496539aeb810d2bb9bba9a5f1d8781aa7e28d42 + STABLE_APK_URL: https://github.com/d1same/triboon/releases/latest/download/triboon.apk run: | apk=android/app/build/outputs/apk/release/app-release.apk + current="${RUNNER_TEMP}/triboon-current-stable.apk" apksigner=$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -1) aapt=$(find "$ANDROID_HOME/build-tools" -type f -name aapt | sort -V | tail -1) test -x "$apksigner" || { echo "apksigner not found"; exit 1; } @@ -243,7 +240,23 @@ jobs: printf '%s\n' "$certs" printf '%s\n' "$certs" | grep -Fi "certificate SHA-256 digest: $EXPECTED_CERT_SHA256" version=${TAG#v} - "$aapt" dump badging "$apk" | grep -F "versionName='$version'" + badging=$("$aapt" dump badging "$apk") + printf '%s\n' "$badging" | grep -F "package: name='app.triboon.tv'" + printf '%s\n' "$badging" | grep -F "versionName='$version'" + new_code=$(printf '%s\n' "$badging" | sed -n "s/^package:.*versionCode='\([0-9][0-9]*\)'.*/\1/p") + test -n "$new_code" || { echo "new APK versionCode is missing"; exit 1; } + + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$current" "$STABLE_APK_URL" + current_certs=$("$apksigner" verify --verbose --print-certs "$current") + printf '%s\n' "$current_certs" | grep -Fi "certificate SHA-256 digest: $EXPECTED_CERT_SHA256" + current_badging=$("$aapt" dump badging "$current") + printf '%s\n' "$current_badging" | grep -F "package: name='app.triboon.tv'" + current_code=$(printf '%s\n' "$current_badging" | sed -n "s/^package:.*versionCode='\([0-9][0-9]*\)'.*/\1/p") + test -n "$current_code" || { echo "stable APK versionCode is missing"; exit 1; } + test "$new_code" -gt "$current_code" || { + echo "release versionCode $new_code must be greater than stable $current_code" + exit 1 + } - name: Stage universal APK assets env: TAG: ${{ github.ref_name }} @@ -260,46 +273,6 @@ jobs: if-no-files-found: error retention-days: 2 - # The PX8 shell is still a preview (web playback; its native bridge is not complete). A manual - # dispatch may build an artifact for QA, but release tags never advertise it as a stable client. - windows-client-preview: - needs: test - if: github.event_name == 'workflow_dispatch' && github.event.inputs.native_client != 'true' - runs-on: windows-latest - timeout-minutes: 40 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24 - # windows-latest ships MSVC (VS 2022 C++ tools) + Rust, but pin the toolchain for determinism. - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - with: - workspaces: clients/windows-px8/src-tauri - - name: Install Tauri CLI (prebuilt, no source compile) - working-directory: clients/windows-px8 - run: npm ci - - name: Build PX8 installer (NSIS) - working-directory: clients/windows-px8 - run: npm run tauri build - - name: Locate installer - shell: bash - run: | - # Tauri NSIS output: "Triboon PX8__x64-setup.exe" (productName has a space) — glob it. - setup=$(ls clients/windows-px8/src-tauri/target/release/bundle/nsis/*-setup.exe | head -1) - test -n "$setup" || { echo "no NSIS installer produced"; exit 1; } - mkdir -p dist - cp "$setup" dist/Triboon-Windows-Client.exe - # Always publish a downloadable build artifact — lets a manual dispatch produce a test .exe - # without attaching to (and misrepresenting) an existing release. - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: Triboon-Windows-Client-preview - path: dist/Triboon-Windows-Client.exe - # Build the one-click Windows server installer as an immutable workflow artifact. Publishing is # owned by publish-release below after every required job succeeds. release-windows-server: @@ -337,7 +310,7 @@ jobs: retention-days: 2 docker-release: - needs: [release-preflight, release-apk, release-windows-server] + needs: [release-preflight, release-apk, release-windows-server, windows-client] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -445,7 +418,7 @@ jobs: NODE publish-release: - needs: [release-preflight, release-apk, release-windows-server, docker-release, verify-public-container-release] + needs: [release-preflight, release-apk, release-windows-server, windows-client, docker-release, verify-public-container-release] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: release @@ -470,13 +443,16 @@ jobs: test -s release-assets/triboon.apk test -s "release-assets/Triboon-Windows-Server-${TAG}.exe" test -s release-assets/Triboon-Windows-Server.exe + test -s "release-assets/Triboon-Windows-Client-${TAG}.exe" + test -s release-assets/Triboon-Windows-Client.exe cmp -s "release-assets/triboon-${TAG}.apk" release-assets/triboon.apk cmp -s "release-assets/Triboon-Windows-Server-${TAG}.exe" release-assets/Triboon-Windows-Server.exe + cmp -s "release-assets/Triboon-Windows-Client-${TAG}.exe" release-assets/Triboon-Windows-Client.exe mapfile -t actual < <(find release-assets -maxdepth 1 -type f -printf '%f\n' | sort) - expected=("Triboon-Windows-Server-${TAG}.exe" "Triboon-Windows-Server.exe" "triboon-${TAG}.apk" "triboon.apk") + expected=("Triboon-Windows-Client-${TAG}.exe" "Triboon-Windows-Client.exe" "Triboon-Windows-Server-${TAG}.exe" "Triboon-Windows-Server.exe" "triboon-${TAG}.apk" "triboon.apk") mapfile -t expected < <(printf '%s\n' "${expected[@]}" | sort) diff -u <(printf '%s\n' "${expected[@]}") <(printf '%s\n' "${actual[@]}") - (cd release-assets && sha256sum "Triboon-Windows-Server-${TAG}.exe" Triboon-Windows-Server.exe "triboon-${TAG}.apk" triboon.apk > SHA256SUMS.txt) + (cd release-assets && sha256sum "Triboon-Windows-Client-${TAG}.exe" Triboon-Windows-Client.exe "Triboon-Windows-Server-${TAG}.exe" Triboon-Windows-Server.exe "triboon-${TAG}.apk" triboon.apk > SHA256SUMS.txt) - name: Publish once, only after every required artifact passed env: GH_TOKEN: ${{ github.token }} @@ -503,15 +479,14 @@ jobs: gh release create "$TAG" release-assets/* --verify-tag --title "Triboon $TAG" --generate-notes --notes "$marker" --draft gh release edit "$TAG" --draft=false --latest - # EXPERIMENTAL native-player (libmpv) build — dispatch-only (native_client=true), artifact only, never - # touches a release. De-risks the libmpv toolchain (M1): fetch the mpv dev bundle, make an MSVC import - # lib, build the Tauri client with the `player` feature, and bundle libmpv-2.dll for the owner to test. - # Once it compiles + runs on a real Windows/GPU box, this folds into release-windows-client. - build-windows-client-native: + # Production Windows client gate. Every PR/main/tag run compiles the native libmpv path with + # MSVC; release tags additionally stage the immutable installer pair for the final publisher. + # Physical GPU decoding remains a documented live gate because hosted CI has no representative + # NVIDIA/AMD/Intel display and `hwdec=auto-safe` alone is not proof that hardware decode engaged. + windows-client: needs: test - if: github.event_name == 'workflow_dispatch' && github.event.inputs.native_client == 'true' runs-on: windows-latest - timeout-minutes: 40 + timeout-minutes: 50 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -520,73 +495,38 @@ jobs: with: node-version: 24 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable-x86_64-pc-windows-msvc - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1; puts lib.exe + MSVC on PATH - - name: Fetch locked libmpv dev bundle (headers + libmpv-2.dll + mpv.def) + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + workspaces: clients/windows-px8/src-tauri + - name: Verify Windows client and release contracts + run: node --test test/windows-px8-player.test.js test/release-contract.test.js + - name: Build, inspect, and stage the locked Windows client shell: pwsh env: - MPV_BUNDLE_FILE: mpv-dev-lgpl-x86_64-20260713-git-e5486b96d7.7z - MPV_BUNDLE_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-13-e5486b96d7/mpv-dev-lgpl-x86_64-20260713-git-e5486b96d7.7z - MPV_BUNDLE_SHA256: 1016b6029da77f96e3a2831d2c33107eee43f798374ba90f56dce45717ed7932 + TAG: ${{ github.ref_name }} run: | - New-Item -ItemType Directory -Force -Path libmpv | Out-Null - $bundle = Join-Path (Resolve-Path libmpv) $env:MPV_BUNDLE_FILE - Invoke-WebRequest -Uri $env:MPV_BUNDLE_URL -OutFile $bundle - $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $bundle).Hash.ToLowerInvariant() - if ($actual -cne $env:MPV_BUNDLE_SHA256) { - throw "libmpv checksum mismatch: expected $env:MPV_BUNDLE_SHA256, got $actual" + $buildParams = @{ + CacheDirectory = Join-Path $env:RUNNER_TEMP 'triboon-libmpv' + ArtifactDirectory = 'dist' } - & 7z x -y $bundle "-o$(Resolve-Path libmpv)" - if ($LASTEXITCODE -ne 0) { throw "7z failed with exit code $LASTEXITCODE" } - Get-ChildItem libmpv -Recurse | Select-Object -First 60 FullName,Length - - name: Generate mpv import lib (robust to bundle layout) - shell: pwsh - run: | - $dll = Get-ChildItem -Path libmpv -Recurse -Filter libmpv-2.dll | Select-Object -First 1 - if (-not $dll) { Write-Error "libmpv-2.dll not found in the bundle"; exit 1 } - $root = $dll.DirectoryName - "libmpv-2.dll at: $($dll.FullName)" - $def = Get-ChildItem -Path libmpv -Recurse -Filter mpv.def | Select-Object -First 1 - if (-not $def) { - # Bundle shipped no .def — synthesize one from the DLL's exported mpv_* symbols. - "no mpv.def in bundle — synthesizing from DLL exports" - $lines = & dumpbin /exports $dll.FullName - $names = $lines | ForEach-Object { if ($_ -match '\s(mpv_[A-Za-z0-9_]+)\s*$') { $Matches[1] } } | Sort-Object -Unique - if (-not $names) { Write-Error "no mpv_* exports found in DLL"; exit 1 } - ("EXPORTS`n" + ($names -join "`n")) | Out-File -Encoding ascii "$root\mpv.def" - $def = Get-Item "$root\mpv.def" + if ($env:GITHUB_REF -like 'refs/tags/v*') { + $buildParams.Tag = $env:TAG } - "using def: $($def.FullName)" - & lib /def:$($def.FullName) /name:libmpv-2.dll /out:"$root\mpv.lib" /machine:X64 - if ($LASTEXITCODE -ne 0) { Write-Error "lib.exe failed"; exit 1 } - Get-ChildItem $root | Select-Object Name,Length - # Expose the bundle root (has include\ + mpv.lib) to later steps for libmpv-sys. - "MPV_SOURCE=$root" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii - - name: Build native client (player feature) - working-directory: clients/windows-px8 - # Default (pwsh) shell on purpose: a bash shell prepends Git's usr\bin, whose link.exe SHADOWS - # MSVC's linker and breaks the Rust build-script link. pwsh keeps MSVC's link.exe (from - # msvc-dev-cmd) on PATH — same as the working P1 job. - run: | - # libmpv-sys tells the linker to use mpv.lib; add its dir to LIB so MSVC's link.exe finds it - # regardless of the crate's emitted search path. - $env:LIB = "$env:MPV_SOURCE;$env:LIB" - Write-Host "MPV_SOURCE=$env:MPV_SOURCE" - # Bundle libmpv-2.dll into the installer (next to the exe) so the installed app loads it at - # runtime. Done via a build-only --config patch so the committed tauri.conf (P1 release) is - # untouched. The DLL is copied next to tauri.conf so the resource path resolves. - Copy-Item (Join-Path $env:MPV_SOURCE 'libmpv-2.dll') 'src-tauri\libmpv-2.dll' -Force - '{"bundle":{"resources":["libmpv-2.dll"]}}' | Out-File -Encoding ascii player.conf.json - npm ci - npm run tauri build -- --features player --config player.conf.json - - name: Stage installer + libmpv runtime DLL - shell: bash - run: | - setup=$(ls clients/windows-px8/src-tauri/target/release/bundle/nsis/*-setup.exe | head -1) - test -n "$setup" || { echo "no NSIS installer produced"; exit 1; } - mkdir -p dist - cp "$setup" dist/Triboon-Windows-Client-native.exe - cp libmpv/libmpv-2.dll dist/ 2>/dev/null || cp libmpv/*/libmpv-2.dll dist/ 2>/dev/null || true + & .\clients\windows-px8\scripts\build-package.ps1 @buildParams + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + with: + name: Triboon-Windows-Client-${{ github.sha }} + path: dist/Triboon-Windows-Client.exe + if-no-files-found: error + retention-days: 7 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ startsWith(github.ref, 'refs/tags/v') }} with: - name: Triboon-Windows-Client-native - path: dist/ + name: release-windows-client + path: dist/Triboon-Windows-Client*.exe + if-no-files-found: error + retention-days: 2 diff --git a/.gitignore b/.gitignore index e55184a..2b9adf7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ bench/* !bench/cut-apk-release.ps1 !bench/android-tv-stress.ps1 !bench/android-tv-smoke.ps1 +!bench/windows-client-smoke-server.js +!bench/android-qa-fixture-server.js docs-release-audit.md # Android: machine-local SDK path, build outputs, gradle caches, built APKs, signing keys. @@ -55,3 +57,10 @@ alltests.txt p2fail*.txt graphify-out/ .codex-remote-attachments/ + +# Windows native-client build products. The checksum-verified DLL is injected by CI/local builds; +# generated capability schemas and compiled binaries must never be committed. +clients/windows-px8/src-tauri/gen/ +clients/windows-px8/src-tauri/libmpv-2.dll +clients/windows-px8/src-tauri/permissions/autogenerated/ +clients/windows-px8/src-tauri/target/ diff --git a/CLAUDE.md b/CLAUDE.md index 2804a90..2046ef8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,11 @@ Always follow the owner's method: - Native clean-room rebuild of nzbdav + UsenetStreamer concepts. - Server stack: Node 24 LTS, zero runtime npm dependencies in `server/`. -- Approved external binaries: ffmpeg for remux/transcode, yt-dlp for Music, and - alass for automatic subtitle sync. alass is a single static binary (in the Docker +- Approved external binaries: ffmpeg for remux/transcode, yt-dlp for Music, + alass for automatic subtitle sync, and the checksum-locked LGPL libmpv runtime + for the native Windows client only. libmpv is dynamically linked, ships with + notices and exact source/rebuild/replacement instructions, and must never be + added to the zero-dependency Node server runtime. alass is a single static binary (in the Docker image via gcompat + the v2.0.0 release) detected at runtime; the auto-sync feature is gated on its presence — absent on a box, the CC path is unchanged. It corrects offset + framerate drift using ffmpeg for audio. Triboon prefers Wyzie's @@ -42,7 +45,10 @@ Always follow the owner's method: - Per-user quality caps are enforced at source selection first, transcoder second. - Clients: one web UI in `web/index.html` for browser/TV spatial navigation, - with Android TV as a Java WebView shell plus native Media3/ExoPlayer handoff. + Android TV as a Java WebView shell plus native Media3/ExoPlayer handoff, and + Windows as a Tauri/WebView2 shell plus native libmpv handoff. Native clients + mirror the same tokened playback callbacks; web state owns watch/next/source + decisions and exactly one native clock reports progress. - Product model: admin configures usenet, indexers, TMDB, subtitles, Trakt, Live TV, and libraries; users never see credentials. - Health: bounded upfront gate plus background triage and auto-advance with @@ -80,6 +86,13 @@ Always follow the owner's method: `TRIBOON_RELEASE_KEY_ALIAS`, `TRIBOON_RELEASE_KEY_PASSWORD` (in the owner's password manager + GitHub Actions secrets, incl. `TRIBOON_RELEASE_KEYSTORE_B64` for CI). See the signing Hard Rule. - Do not use `C:\Users\opencode\tools\gradle-8.10.2`. +- Windows client build: + - Use Rust stable `x86_64-pc-windows-msvc` from a VS 2022 C++ developer shell. + - Use only the immutable LGPL libmpv URL/SHA in the GitHub workflow; do not + resolve a mutable latest asset or substitute a GPL bundle. + - Run the client Rust tests with `--features player`, build the NSIS installer, + and verify the packaged DLL/notices plus the real GPU/live matrix in + `VERIFY.md`. A requested hwdec mode is not proof of GPU decode. Everything is configured in the web dashboard after first-run setup and stored under `./data`. Credential-bearing settings are encrypted at rest; account, @@ -174,7 +187,7 @@ Still open: - Broader Android hardware QA matrix for Shield, Onn, Fire TV, Chromecast, Google TV, and low-memory devices. - Real multi-user VOD stress runs across several 1080p and 4K starts/seeks. -- Tauri desktop. +- Windows ARM64 and the broader physical GPU/HDR/receiver QA matrix. - par2 repair and compressed RAR streaming improvements. - MDBList and richer catalog rows. - Intro/credit skip. @@ -193,6 +206,13 @@ Still open: resolves to the newest build. (The legacy `triboon-tv/mobile.apk` names were retired after v1.7.67 once every device ran a build whose in-app updater accepts `triboon.apk`.) +- The same release must carry byte-identical versioned/stable Windows client + installers (`Triboon-Windows-Client-vX.Y.Z.exe` and + `Triboon-Windows-Client.exe`) built from the tag commit by the normal + MSVC/libmpv gate. The installer must contain `libmpv-2.dll`, notices, + `LIBMPV-LICENSE.LGPL`, and `LIBMPV-SOURCE.md`. Until a protected code-signing identity is configured, + describe the installer as unsigned and expect SmartScreen; never commit or + log signing secrets. - App signing (CRITICAL): there is ONE dedicated Android release keystore. ALWAYS sign release APKs with it — via CI (the `release-apk` job builds an immutable artifact and the final publisher publishes only after every required asset passes) or locally with `npm run release:apk`. NEVER diff --git a/README.md b/README.md index 7cea174..ea19d2b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ | Android APK | + Windows client + | Windows server | Container image @@ -138,8 +140,22 @@ when the package id and signing key match and the new `versionCode` is higher. ## Windows -One stable Windows server installer ships on every release. It has a fixed -"latest" download plus a versioned copy you can pin or roll back to. +Triboon ships separate Windows installers for watching and hosting. Both have a +fixed "latest" download plus a versioned copy that can be pinned or rolled back. + +### Client (watch on a Windows PC) + +The native Windows 10/11 x64 client connects to an existing Triboon server and +uses libmpv with D3D11 hardware decoding on supported NVIDIA, AMD, and Intel +GPUs. Unsupported codecs or drivers fall back to software decoding without +breaking playback. It includes mouse, keyboard, media-key, controller/D-pad, +subtitle, audio-track, quality, episode, Continue Watching, and Live TV support. + +```text +https://github.com/d1same/triboon/releases/latest/download/Triboon-Windows-Client.exe +``` + +The matching immutable filename is `Triboon-Windows-Client-vX.Y.Z.exe`. ### Server (host Triboon on Windows) @@ -159,17 +175,10 @@ the installer keeps on upgrade *and* uninstall - reinstalling picks up exactly where you left off. Updates only replace the program files under `Program Files\Triboon`. -### Experimental client preview - -The PX8/Tauri client is available only as a manual GitHub Actions preview -artifact. It is not attached to stable releases and has no fixed latest URL. -The current shell still uses web playback because its native playback bridge is -not complete; the separate libmpv experiment is also preview-only. - -The Windows server installer and preview artifacts are currently unsigned, so +The Windows client and server installers are currently unsigned, so Windows SmartScreen shows a warning on first run - choose -**More info -> Run anyway**. Each stable release keeps this versioned server -copy for history and rollback: +**More info -> Run anyway**. Each stable release also keeps this versioned +server copy for history and rollback: ```text Triboon-Windows-Server-vX.Y.Z.exe @@ -308,8 +317,8 @@ repository root: - `web/index.html` - the single-file web UI used by browser, desktop wrapper, and Android WebView shell. - `android/` - Android TV shell with D-pad bridge and native Media3/ExoPlayer. -- `clients/windows-px8/` - experimental Tauri/PX8 Windows preview; not a stable - release asset until the native playback bridge is complete. +- `clients/windows-px8/` - Tauri/libmpv Windows client with secure remote bridge, + D3D11 hardware decoding, native controls, and release packaging. - `installer/windows/` - one-click Windows server installer (Inno Setup + service wrapper); build with `installer/windows/build-installer.ps1`. - `unraid/` - Unraid template. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 328e3e6..cdc01d0 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -42,10 +42,21 @@ installer payload. The upstream projects and their authors are not affiliated with or responsible for Triboon. -## Experimental Windows native-client artifact - -The dispatch-only experimental native Windows-client artifact bundles the -`mpv-dev-lgpl-x86_64-20260713-git-e5486b96d7` development/runtime package from -`zhongfly/mpv-winbuild`. That package is locked by URL and SHA-256 in the CI -workflow and uses mpv's LGPL build. mpv and its bundled libraries remain under -their respective licenses; see the [mpv copyright and license notices](https://github.com/mpv-player/mpv/blob/master/Copyright). +## Windows native client + +Triboon for Windows dynamically loads `libmpv-2.dll` from the unmodified +`mpv-dev-lgpl-x86_64-20260713-git-e5486b96d7` package published by +`zhongfly/mpv-winbuild`. The archive URL and SHA-256 are locked in CI. The +selected package is the LGPL x86-64 build; Triboon does not statically link it +or prevent replacement with an ABI-compatible DLL. + +The upstream archive contains a single runtime DLL rather than separate +codec/rendering DLLs; its enabled components remain under their respective +licenses. The installed client carries this notice, Triboon's `LICENSE`, +`LIBMPV-LICENSE.LGPL`, `LIBMPV-SOURCE.md`, and a generated +`RUST-DEPENDENCIES.md` inventory for the locked Windows Cargo graph. +`LIBMPV-SOURCE.md` records the exact archive and DLL hashes, mpv source +revision, builder revision/run, rebuild route, runtime prerequisite, license +links, and replacement instructions. See +the [exact mpv copyright notice](https://github.com/mpv-player/mpv/blob/e5486b96d7d06dd148337899bfdc46bf25101663/Copyright) +and [LGPL license](https://github.com/mpv-player/mpv/blob/e5486b96d7d06dd148337899bfdc46bf25101663/LICENSE.LGPL). diff --git a/VERIFY.md b/VERIFY.md index d9bd85c..e8c8090 100644 --- a/VERIFY.md +++ b/VERIFY.md @@ -18,6 +18,10 @@ setup instructions; use `README.md`, `docs-setup.md`, and blocker. - Do not claim Web Player or Android ExoPlayer coverage from code inspection alone. +- Do not claim Windows GPU playback from a successful compile or requested mpv + option. A real Windows playback smoke must show advancing frames and a + hardware value from `hwdec-current`; otherwise report software fallback or + unverified. - Do not claim IPTV coverage without source/cache checks and at least one real channel start path. - Do not claim subtitles/CC coverage unless captions are selectable, visible, @@ -66,9 +70,14 @@ changes, complete these live checks before saying the update is done: | Web Player VOD | Movie starts, seeks, pauses, resumes, closes, and does not show new console errors. | | Web Player TV episode | Correct season/episode context, resume target, Up Next behavior when relevant. | | Episode handoff (web + Android) | Manual Play Next, autoplay at EOF, and player episode-strip selection keep the old frame or branded player loader topmost; the TV-show details page never appears between episodes. Autoplay uses the final pre-EOF 10-second choice window and does not start another countdown at EOF. Back during Preparing cancels the pending handoff permanently; stale success/error/native callbacks cannot reopen or close a newer player. | +| Nested page Back (web + Android) | Visit Movie A -> Related B -> Cast -> Related C. Visible Back, browser Back, Escape/Backspace, and Android/TV hardware Back unwind C -> Cast -> B -> A -> the originating browse page, one page per press. A deep-focused person filmography must leave the person page immediately; a direct deep link with no prior in-app page uses the safe origin fallback. | | Web Live TV | Channel starts in Triboon's web player, retunes cleanly, and shows live-specific errors instead of a generic external-player panel. | | Android ExoPlayer VOD | Movie or episode opens the native branded loader and ExoPlayer surface, not the web video shell; seek does not show the full startup loader. | | Android ExoPlayer Live TV | Native Live TV uses ExoPlayer, survives at least 20 Up/Down zaps, and logcat has no fatal/provider-loop markers. | +| Windows native VOD | On Windows 10/11 x64, an H.264 1080p title and HEVC Main10 4K title open the dedicated libmpv surface. Start, pause/resume, seeks/skips, fullscreen, close, direct/remux/transcode fallback, and a simulated sustained stall remain responsive. Diagnostics name the actual decoder; claim GPU only when `hwdec-current` is hardware-backed while frames advance. | +| Windows episode/resume | Saved resume opens at the correct absolute position. Manual next, autoplay at EOF, and episode-strip selection reuse the native surface without revealing details. Close/error/final checkpoint reaches Continue Watching promptly and stale token callbacks cannot change the replacement episode. | +| Windows subtitles/audio/input | VTT selection/version/sync/size and audio-track switching do not restart or desync video. Mouse, keyboard, media keys, fullscreen, Back, and D-pad/controller navigation work at normal and high-DPI scales. | +| Windows native Live TV | Provider HLS and TS plus server fallback start in libmpv; at least 20 rapid zaps release/replace the old stream without stale playback or fatal logs. | | CC/subtitles | Web and native CC choices open; recommended row is sane; captions stay in-frame; sync/version changes do not restart video or reset captions to time zero. | | Fast startup | A warm prepared movie or episode starts through the reuse path, not a repeated search/probe/mount/health gate. Healthy warm sources should stay near the 1-2 second target. | | Warm next episode | At 90 seconds remaining, the exact next S/E is prepared once. Manual/autoplay next joins that work and the prewarmed local lookup; it does not repeat the indexer/NZB/mount path. Out-of-order metadata from an older episode cannot replace the current player's next target. | @@ -712,6 +721,27 @@ Manual checks: - Web captions respect mobile/TV safe areas and bounded height; with built-ins off, online warmup does not wait on the optional track probe. +### Windows Native Client / P15 + +```powershell +node --test test/windows-px8-player.test.js +node --test test/release-contract.test.js + +# From the repository root. This is the same locked recipe used by CI; it +# imports MSVC, tests Rust, builds NSIS, extracts it, and byte-checks its payload. +powershell -ExecutionPolicy Bypass -File ` + .\clients\windows-px8\scripts\build-package.ps1 -Tag v2.8.0 +``` + +The normal pull-request/main/tag workflow performs the locked MSVC native build. +For a tag it must publish only the byte-identical versioned/stable installer +pair into the final whitelist. Inspect the installed payload for +`libmpv-2.dll`, `LICENSE`, `THIRD-PARTY-NOTICES.md`, +`LIBMPV-LICENSE.LGPL`, `LIBMPV-SOURCE.md`, and `RUST-DEPENDENCIES.md`. Confirm +the current graphics driver provides `C:\Windows\System32\vulkan-1.dll`; the +pinned libmpv imports it and the installer intentionally does not bundle it. A +CI runner does not replace the four Windows live-smoke rows above. + ### Release Reproducibility / Privacy ```powershell @@ -720,8 +750,9 @@ node --test --test-name-pattern "privacy|geolocation|proxy" test/security.test.j ``` Confirm tag/package/Android versions agree, release assets are immutable and -whitelisted, APK aliases are identical and release-signed, Windows dependencies -are locked, and the final publisher cannot expose a partial release. Confirm +whitelisted, every stable/versioned alias is identical, the APK is +release-signed, Windows server dependencies and the LGPL libmpv archive are +locked, and the final publisher cannot expose a partial release. Confirm viewer geolocation is off by default, trusted-proxy handling is explicit, and the Settings status reflects any environment-forced state. @@ -745,6 +776,10 @@ Every final update report and PR description must include: - Docker image build and isolated container `/api/server` smoke result; - locked Windows installer build/runtime result and any elevated install, service, firewall, upgrade, or uninstall smoke not run, with reason; +- Windows client MSVC/Rust/build result, installed runtime/notices inspection, + exact GPU/driver plus `hwdec-current`, 1080p/4K VOD, next/resume/CW, + subtitle/audio/input, IPTV zap, clean-install/upgrade/uninstall results, and + whether the installer was Authenticode-signed; - anything not run, with reason and risk. If any required line says `not run`, do not phrase the work as fully done. diff --git a/android/app/build.gradle b/android/app/build.gradle index c30c15a..1b477d0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -27,8 +27,8 @@ android { applicationId = "app.triboon.tv" minSdk = 23 // Media3 1.10.x requires API 23+; Shield/onn/Chromecast TV targets clear this targetSdk = 36 - versionCode = 304 - versionName = "2.7.1" + versionCode = 305 + versionName = "2.8.0" } signingConfigs { diff --git a/android/app/src/main/java/app/triboon/tv/MainActivity.java b/android/app/src/main/java/app/triboon/tv/MainActivity.java index dfbd2dc..332ecd3 100644 --- a/android/app/src/main/java/app/triboon/tv/MainActivity.java +++ b/android/app/src/main/java/app/triboon/tv/MainActivity.java @@ -153,6 +153,7 @@ public class MainActivity extends Activity { private static final int NATIVE_BACKDROP_MAX_BYTES = 6 * 1024 * 1024; private static final int NATIVE_BACKDROP_MAX_WIDTH = 1280; private static final int NATIVE_BACKDROP_MAX_HEIGHT = 720; + private static final long APP_UPDATE_MAX_BYTES = 256L * 1024L * 1024L; private WebView web; private LinearLayout setup; @@ -748,14 +749,9 @@ private boolean allowedAppUpdateUrl(Uri uri) { String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.US); String path = uri.getPath() == null ? "" : uri.getPath(); if (!"https".equals(scheme) || !"github.com".equals(host)) return false; - // Accept the stable Triboon release asset under ANY owner/repo on github.com. This allowlist - // is baked into the installed APK, so pinning it to a single repo name meant a future GitHub - // rename would strand the in-app updater on every existing device. Still strictly locked to - // https + github.com + the Triboon asset filenames + the /releases/latest/download/ path, and - // the URL itself only ever comes from the trusted server-served UI — so a rename just works. - // Accept the canonical single asset (triboon.apk) AND the legacy tv/mobile aliases, so this - // build can update from either during the one-APK migration. - return path.matches("/[^/]+/[^/]+/releases/latest/download/triboon(-(tv|mobile))?\\.apk"); + // Pin the official repository and stable asset names. The downloaded archive is also + // checked for package id, production signer, and a higher versionCode before installation. + return UpdateVerifier.allowedGithubReleasePath(path); } private void openExternalUrl(String rawUrl) { @@ -778,8 +774,8 @@ private void openExternalUrl(String rawUrl) { private java.io.File cachedUpdateApk = null; // last APK downloaded this session — a re-press installs it instantly // Download the signed release APK (same allowlisted GitHub URL the browser path uses) and hand - // it straight to the system package installer — no browser/downloader detour. Any failure - // falls back to openExternalUrl so the user can still get the update. + // it straight to the system package installer — no browser/downloader detour. A failed + // download or verification never reaches Package Installer. private void downloadAndInstallUpdate(String rawUrl) { final String url = rawUrl == null ? "" : rawUrl.trim(); Uri uri = Uri.parse(url); @@ -800,10 +796,12 @@ private void downloadAndInstallUpdate(String rawUrl) { } // Already downloaded this session? Go straight to the installer — if the install prompt didn't // surface the first time, a re-press installs instantly instead of downloading all over again. - if (cachedUpdateApk != null && cachedUpdateApk.isFile() && cachedUpdateApk.length() > 100000) { + if (cachedUpdateApk != null && cachedUpdateApk.isFile() + && cachedUpdateApk.length() > 100000 && verifyDownloadedUpdate(cachedUpdateApk)) { launchApkInstall(cachedUpdateApk); return; } + cachedUpdateApk = null; updateInProgress = true; Toast.makeText(this, "Downloading update…", Toast.LENGTH_SHORT).show(); new Thread(() -> { @@ -818,23 +816,78 @@ private void downloadAndInstallUpdate(String rawUrl) { if (code != 200) throw new java.io.IOException("HTTP " + code); try (java.io.InputStream in = conn.getInputStream(); java.io.FileOutputStream fos = new java.io.FileOutputStream(out)) { - byte[] buf = new byte[65536]; int n; - while ((n = in.read(buf)) != -1) fos.write(buf, 0, n); + byte[] buf = new byte[65536]; int n; long total = 0; + while ((n = in.read(buf)) != -1) { + total += n; + if (total > APP_UPDATE_MAX_BYTES) { + throw new java.io.IOException("downloaded update is too large"); + } + fos.write(buf, 0, n); + } } conn.disconnect(); if (out.length() < 100000) throw new java.io.IOException("downloaded file too small"); + if (!verifyDownloadedUpdate(out)) { + throw new java.io.IOException("downloaded update failed verification"); + } runOnUiThread(() -> { updateInProgress = false; cachedUpdateApk = out; launchApkInstall(out); }); } catch (Exception e) { + if (out.isFile()) out.delete(); runOnUiThread(() -> { updateInProgress = false; - Toast.makeText(this, "In-app update failed; opening the download instead", Toast.LENGTH_SHORT).show(); - openExternalUrl(url); + Toast.makeText(this, "Update download or verification failed", Toast.LENGTH_LONG).show(); }); } }, "triboon-update-dl").start(); } + private boolean verifyDownloadedUpdate(java.io.File apk) { + if (apk == null || !apk.isFile() || apk.length() < 100000 || apk.length() > APP_UPDATE_MAX_BYTES) { + return false; + } + try { + android.content.pm.PackageManager pm = getPackageManager(); + android.content.pm.PackageInfo candidate; + android.content.pm.Signature[] signers; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + candidate = pm.getPackageArchiveInfo(apk.getAbsolutePath(), + android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES); + if (candidate == null || candidate.signingInfo == null) return false; + signers = candidate.signingInfo.getApkContentsSigners(); + } else { + candidate = pm.getPackageArchiveInfo(apk.getAbsolutePath(), + android.content.pm.PackageManager.GET_SIGNATURES); + if (candidate == null) return false; + signers = candidate.signatures; + } + if (signers == null || signers.length != 1) return false; + String signer = sha256Hex(signers[0].toByteArray()); + android.content.pm.PackageInfo installed = pm.getPackageInfo(getPackageName(), 0); + long candidateCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? candidate.getLongVersionCode() : candidate.versionCode; + long installedCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? installed.getLongVersionCode() : installed.versionCode; + return UpdateVerifier.metadataAllowed(candidate.packageName, candidateCode, + installedCode, signer); + } catch (Exception ignored) { + return false; + } + } + + private String sha256Hex(byte[] value) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value); + StringBuilder out = new StringBuilder(digest.length * 2); + for (byte b : digest) out.append(String.format(Locale.US, "%02x", b & 0xff)); + return out.toString(); + } + private void launchApkInstall(java.io.File apk) { + if (!verifyDownloadedUpdate(apk)) { + if (apk != null && apk.isFile()) apk.delete(); + cachedUpdateApk = null; + Toast.makeText(this, "Update verification failed", Toast.LENGTH_LONG).show(); + return; + } try { Uri apkUri = androidx.core.content.FileProvider.getUriForFile(this, getPackageName() + ".updateprovider", apk); Intent intent = new Intent(Intent.ACTION_VIEW); @@ -1395,7 +1448,7 @@ public void showVideoLoading(String json) { @android.webkit.JavascriptInterface public void closeVideo() { if (!trustedBridgeOrigin()) return; - runOnUiThread(() -> closeNativePlayback(false)); + runOnUiThread(MainActivity.this::closeNativePlaybackForWebSurface); } @android.webkit.JavascriptInterface @@ -7025,6 +7078,42 @@ private void closeNativePlayback(boolean notifyClosed) { showWebAfterNativePlayback(); } + /** + * The web bridge uses this path when it is about to reveal a browser-owned surface (details, + * Multiview, and similar screens). ExoPlayer.release() can wait several seconds for a provider + * read to unwind on slower devices. Keeping the opaque native layer visible during that wait + * made a successful transition look like a black screen. + * + * Stop the load, detach the visible native surface, and let WebView draw one frame first. The + * same UI-thread player cleanup still follows immediately; only its placement behind the + * surface handoff changes. The identity check prevents the delayed task from releasing a newer + * player if another native playback starts during the handoff. + */ + private void closeNativePlaybackForWebSurface() { + ExoPlayer closingPlayer = nativePlayer; + if (closingPlayer == null) { + setPhonePlaybackOrientation(false); + if (nativePlayerLayer != null) nativePlayerLayer.setVisibility(View.GONE); + showWebAfterNativePlayback(); + return; + } + try { + closingPlayer.stop(); + } catch (Throwable e) { + Log.w(TAG, "Native player stop before WebView handoff ignored: " + nativeThrowableMessage(e)); + } + setPhonePlaybackOrientation(false); + if (nativePlayerLayer != null) nativePlayerLayer.setVisibility(View.GONE); + showWebAfterNativePlayback(); + if (web != null) { + web.postDelayed(() -> { + if (nativePlayer == closingPlayer) releaseNativePlayer(false); + }, 90); + } else if (nativePlayer == closingPlayer) { + releaseNativePlayer(false); + } + } + private void showWebAfterNativePlayback() { if (web == null) return; web.setVisibility(View.VISIBLE); diff --git a/android/app/src/main/java/app/triboon/tv/UpdateVerifier.java b/android/app/src/main/java/app/triboon/tv/UpdateVerifier.java new file mode 100644 index 0000000..9a4bf8a --- /dev/null +++ b/android/app/src/main/java/app/triboon/tv/UpdateVerifier.java @@ -0,0 +1,28 @@ +package app.triboon.tv; + +import java.util.Locale; + +/** Pure update-chain policy shared by the Android shell and local unit tests. */ +final class UpdateVerifier { + static final String PACKAGE_NAME = "app.triboon.tv"; + static final String RELEASE_CERT_SHA256 = + "c0b1e2d90b443b07fe4ec4001496539aeb810d2bb9bba9a5f1d8781aa7e28d42"; + + private UpdateVerifier() {} + + static boolean allowedGithubReleasePath(String path) { + if (path == null) return false; + return "/d1same/triboon/releases/latest/download/triboon.apk".equals(path); + } + + static boolean metadataAllowed(String packageName, long candidateVersionCode, + long installedVersionCode, String signerSha256) { + return PACKAGE_NAME.equals(packageName) + && candidateVersionCode > installedVersionCode + && RELEASE_CERT_SHA256.equals(normalizeSha256(signerSha256)); + } + + static String normalizeSha256(String value) { + return value == null ? "" : value.replace(":", "").trim().toLowerCase(Locale.US); + } +} diff --git a/android/app/src/test/java/app/triboon/tv/UpdateVerifierTest.java b/android/app/src/test/java/app/triboon/tv/UpdateVerifierTest.java new file mode 100644 index 0000000..4ba4657 --- /dev/null +++ b/android/app/src/test/java/app/triboon/tv/UpdateVerifierTest.java @@ -0,0 +1,35 @@ +package app.triboon.tv; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class UpdateVerifierTest { + @Test + public void releaseUrlIsPinnedToOfficialStableAssets() { + assertTrue(UpdateVerifier.allowedGithubReleasePath( + "/d1same/triboon/releases/latest/download/triboon.apk")); + assertFalse(UpdateVerifier.allowedGithubReleasePath( + "/d1same/triboon/releases/latest/download/triboon-tv.apk")); + assertFalse(UpdateVerifier.allowedGithubReleasePath( + "/d1same/triboon/releases/latest/download/triboon-mobile.apk")); + assertFalse(UpdateVerifier.allowedGithubReleasePath( + "/attacker/repo/releases/latest/download/triboon.apk")); + assertFalse(UpdateVerifier.allowedGithubReleasePath( + "/d1same/triboon/releases/download/v2.8.0/triboon.apk")); + } + + @Test + public void updateMustBeNewerTriboonAndProductionSigned() { + String signer = UpdateVerifier.RELEASE_CERT_SHA256; + assertTrue(UpdateVerifier.metadataAllowed("app.triboon.tv", 305, 304, signer)); + assertTrue(UpdateVerifier.metadataAllowed("app.triboon.tv", 305, 304, + signer.toUpperCase().replaceAll("(..)(?!$)", "$1:"))); + assertFalse(UpdateVerifier.metadataAllowed("other.app", 305, 304, signer)); + assertFalse(UpdateVerifier.metadataAllowed("app.triboon.tv", 304, 304, signer)); + assertFalse(UpdateVerifier.metadataAllowed("app.triboon.tv", 303, 304, signer)); + assertFalse(UpdateVerifier.metadataAllowed("app.triboon.tv", 305, 304, + "00".repeat(32))); + } +} diff --git a/bench/android-qa-fixture-server.js b/bench/android-qa-fixture-server.js new file mode 100644 index 0000000..4bc27e1 --- /dev/null +++ b/bench/android-qa-fixture-server.js @@ -0,0 +1,155 @@ +'use strict'; + +// Deterministic, credential-free Android playback fixture. It provides two local Newznab +// quality classes, a mock NNTP provider, and a 32-channel M3U backed by seekable MP4 media. +// Real user settings/data are never read. Configure an isolated Triboon data directory to use: +// provider 127.0.0.1:53159 (no TLS/auth) +// indexer http://127.0.0.1:60993 +// M3U http://127.0.0.1:60993/playlist.m3u + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { encodePart } = require('../server/yenc'); +const { createMockNntp } = require('../test/mock-nntp'); + +const root = path.resolve(__dirname, '..'); +const httpPort = Number(process.env.TRIBOON_QA_HTTP_PORT || 60993); +const nntpPort = Number(process.env.TRIBOON_QA_NNTP_PORT || 53159); +const hdPath = path.resolve(process.env.TRIBOON_QA_HD_MEDIA || path.join(root, 'tmp', 'windows-smoke-1080-dual-long.mp4')); +const uhdPath = path.resolve(process.env.TRIBOON_QA_UHD_MEDIA || path.join(root, 'tmp', 'windows-smoke-4k-long.mp4')); +const partSize = Math.max(256 * 1024, Number(process.env.TRIBOON_QA_PART_BYTES || 4 * 1024 * 1024)); + +for (const file of [hdPath, uhdPath]) { + if (!fs.existsSync(file)) throw new Error(`Missing QA media fixture: ${file}`); +} + +function xml(value) { + return String(value).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])); +} + +function releaseFromFile(file, name, prefix) { + const data = fs.readFileSync(file); + const articles = new Map(); + const segments = []; + const totalParts = Math.ceil(data.length / partSize); + for (let index = 0; index < totalParts; index++) { + const begin = index * partSize; + const end = Math.min(data.length, begin + partSize); + const messageId = `${prefix}-${index + 1}@qa.triboon.local`; + const body = encodePart(data, { + name, + partNum: index + 1, + totalParts, + begin, + end, + totalSize: data.length, + }); + articles.set(messageId, body); + segments.push(`${messageId}`); + } + const nzb = `alt.binaries.triboon.qa${segments.join('')}`; + return { nzb, articles, size: data.length }; +} + +const hd = releaseFromFile(hdPath, 'Triboon.QA.Movie.2025.1080p.WEB-DL.DDP5.1.H.264-NTb.mp4', 'qa-hd'); +const uhd = releaseFromFile(uhdPath, 'Triboon.QA.Movie.2025.2160p.WEB-DL.DDP5.1.HEVC-FLUX.mp4', 'qa-uhd'); +const articles = new Map([...hd.articles, ...uhd.articles]); +const nntp = createMockNntp({ articles }); + +const releases = [ + { name: 'The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.1080p.WEB-DL.H.264-NTb', size: 6_000_000_000, nzb: hd.nzb }, + { name: 'The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.2160p.WEB-DL.HEVC-FLUX', size: 16_000_000_000, nzb: uhd.nzb }, + { name: 'Triboon.QA.Movie.2025.1080p.WEB-DL.DDP5.1.H.264-NTb', size: 6_000_000_000, nzb: hd.nzb }, + { name: 'Triboon.QA.Movie.2025.2160p.WEB-DL.DDP5.1.HEVC-FLUX', size: 16_000_000_000, nzb: uhd.nzb }, +]; + +function rss() { + const items = releases.map((release, index) => `${xml(release.name)}triboon-qa-${index}`).join(''); + return `Triboon QA${items}`; +} + +function playlist() { + const lines = ['#EXTM3U']; + for (let i = 1; i <= 32; i++) { + lines.push(`#EXTINF:-1 tvg-id="qa-${i}" tvg-name="Triboon QA ${i}" group-title="QA",Triboon QA ${i}`); + lines.push(`http://127.0.0.1:${httpPort}/live/1080.mp4?channel=${i}`); + } + return `${lines.join('\n')}\n`; +} + +function sendFile(req, res, file) { + const stat = fs.statSync(file); + const range = /^bytes=(\d*)-(\d*)$/i.exec(req.headers.range || ''); + let start = 0; + let end = stat.size - 1; + if (range) { + if (range[1]) start = Number(range[1]); + if (range[2]) end = Math.min(end, Number(range[2])); + if (!range[1] && range[2]) start = Math.max(0, stat.size - Number(range[2])); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start > end || start >= stat.size) { + res.writeHead(416, { 'content-range': `bytes */${stat.size}` }); + return res.end(); + } + } + const headers = { + 'accept-ranges': 'bytes', + 'content-type': 'video/mp4', + 'content-length': end - start + 1, + 'cache-control': 'no-store', + }; + if (range) headers['content-range'] = `bytes ${start}-${end}/${stat.size}`; + res.writeHead(range ? 206 : 200, headers); + if (req.method === 'HEAD') return res.end(); + fs.createReadStream(file, { start, end }).pipe(res); +} + +const api = http.createServer((req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${httpPort}`); + if (url.pathname === '/health') { + res.writeHead(200, { 'content-type': 'application/json' }); + return res.end(JSON.stringify({ ok: true, releases: releases.length, channels: 32 })); + } + if (url.pathname === '/api') { + res.writeHead(200, { 'content-type': 'application/rss+xml; charset=utf-8' }); + return res.end(rss()); + } + const nzbMatch = /^\/nzb\/(\d+)$/.exec(url.pathname); + if (nzbMatch && releases[Number(nzbMatch[1])]) { + res.writeHead(200, { 'content-type': 'application/x-nzb; charset=utf-8' }); + return res.end(releases[Number(nzbMatch[1])].nzb); + } + if (url.pathname === '/playlist.m3u') { + res.writeHead(200, { 'content-type': 'audio/x-mpegurl; charset=utf-8' }); + return res.end(playlist()); + } + if (url.pathname === '/live/1080.mp4') return sendFile(req, res, hdPath); + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('not found'); +}); + +async function listen(server, port) { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); +} + +async function close() { + await Promise.allSettled([ + new Promise((resolve) => api.close(resolve)), + nntp.close(), + ]); +} + +process.on('SIGINT', () => close().finally(() => process.exit(0))); +process.on('SIGTERM', () => close().finally(() => process.exit(0))); + +(async () => { + await listen(nntp.server, nntpPort); + await listen(api, httpPort); + process.stdout.write(`Triboon Android QA fixture ready: HTTP ${httpPort}, NNTP ${nntpPort}, ${articles.size} articles\n`); +})().catch((error) => { + process.stderr.write(`${error.stack || error}\n`); + process.exitCode = 1; +}); diff --git a/bench/android-tv-stress.ps1 b/bench/android-tv-stress.ps1 index 6f45251..08c6aeb 100644 --- a/bench/android-tv-stress.ps1 +++ b/bench/android-tv-stress.ps1 @@ -147,7 +147,16 @@ function Invoke-CdpJson { const port = process.env.TRIBOON_CDP_PORT || '9223'; const expr = process.env.TRIBOON_CDP_EXPR || '({})'; const awaitPromise = process.env.TRIBOON_CDP_AWAIT === '1'; -const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json()); +const bounded = (promise, ms, label) => { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms); }) + ]).finally(() => clearTimeout(timer)); +}; +const targets = await fetch(`http://127.0.0.1:${port}/json/list`, { + signal: AbortSignal.timeout(10000) +}).then((r) => r.json()); const list = Array.isArray(targets) ? targets : targets.value; const target = list.find((t) => t.webSocketDebuggerUrl && t.url && t.url !== 'about:blank') || list.find((t) => t.webSocketDebuggerUrl); @@ -162,17 +171,22 @@ ws.onmessage = (ev) => { pending.delete(msg.id); msg.error ? p.reject(new Error(msg.error.message || JSON.stringify(msg.error))) : p.resolve(msg.result); }; -await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); const send = (method, params = {}) => new Promise((resolve, reject) => { const mid = ++id; pending.set(mid, { resolve, reject }); ws.send(JSON.stringify({ id: mid, method, params })); }); -await send('Runtime.enable'); -const result = await send('Runtime.evaluate', { expression: expr, awaitPromise, returnByValue: true, timeout: 45000 }); -const value = result.result && Object.prototype.hasOwnProperty.call(result.result, 'value') ? result.result.value : result.result; -console.log(typeof value === 'string' ? value : JSON.stringify(value || null)); -ws.close(); +try { + await bounded(new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }), 10000, 'CDP WebSocket open'); + await bounded(send('Runtime.enable'), 10000, 'Runtime.enable'); + const result = await bounded(send('Runtime.evaluate', { + expression: expr, awaitPromise, returnByValue: true, timeout: 45000 + }), 55000, 'Runtime.evaluate'); + const value = result.result && Object.prototype.hasOwnProperty.call(result.result, 'value') ? result.result.value : result.result; + console.log(typeof value === 'string' ? value : JSON.stringify(value || null)); +} finally { + try { ws.close(); } catch {} +} '@ | node - if ($LASTEXITCODE -eq 0) { return ($raw | ConvertFrom-Json) } Start-Sleep -Milliseconds (650 + ($attempt * 500)) @@ -488,6 +502,37 @@ $multiLiveOpen = Invoke-CdpJson @" }; })() "@ +# A native-surface focus handoff can consume the first synthetic OK on the emulator even though the +# visible WebView button still owns focus. Retry once only when we are still on Live TV with that +# exact launcher visible/focused; retain the first sample so a persistent regression is not masked. +if (!$multiLiveOpen.ok) { + $multiLiveRetryReady = Invoke-CdpJson @" +(() => { + const btn = document.getElementById('chMultiBtn'); + const ready = S.view === 'livetv' && btn && btn.offsetParent !== null && !(S.multiView && S.multiView.open); + if (ready) focusLiveToolbar('chMultiBtn'); + return { ok: !!ready, view: S.view, focus: document.activeElement && (document.activeElement.id || document.activeElement.className || document.activeElement.tagName) }; +})() +"@ + if ($multiLiveRetryReady.ok -and $multiLiveRetryReady.focus -eq 'chMultiBtn') { + $multiLiveFirstAttempt = $multiLiveOpen + $multiLiveFirstJson = $multiLiveFirstAttempt | ConvertTo-Json -Compress + Send-Key "DPAD_CENTER" + Start-Sleep -Seconds 3 + $multiLiveOpen = Invoke-CdpJson @" +(() => ({ + ok: S.view === 'multiview' && !!(S.multiView && S.multiView.open) && !!document.querySelector('#multiView.open'), + view: S.view, + multiOpen: !!(S.multiView && S.multiView.open), + pickerOpen: !!document.querySelector('#mvPicker.open'), + count: S.multiView ? S.multiView.count : 0, + active: S.multiView ? S.multiView.active : null, + retriedAfterFocusedHandoff: true, + firstAttempt: $multiLiveFirstJson +}))() +"@ + } +} if (!$multiLiveOpen.ok) { Add-Failure "Live TV Multiview did not open from Android D-pad OK" } $multiLiveCleanup = Invoke-CdpJson @" (async () => { @@ -659,11 +704,24 @@ $vodStart = Invoke-CdpJson @" if (!S.rows || !S.rows.length) await loadRows(); await wait(500); let item = (S.rows || []).flatMap((r) => r.items || []).find((x) => x && x.key === '$VodKey'); - if (!item && ($VodQualityRank === 4 || $VodResumeSeconds > 0)) { - return { ok: false, error: 'exact -VodKey is required for 4K/Continue Watching verification', key: '$VodKey' }; + // A zero-progress QA title is correctly absent from Continue Watching. Resolve the exact key from + // Watchlist/watch state instead of silently playing an unrelated home card; every quality rank + // must test the requested fixture, not only 4K/resume runs. + if (!item) { + try { + const rows = await api('/api/watchlist'); + const row = Array.isArray(rows) ? rows.find((x) => x && x.key === '$VodKey') : null; + if (row) item = { ...(row.meta || {}), key: row.key }; + } catch {} + } + if (!item) { + try { + const rows = await api('/api/watch' + profileQ()); + const row = Array.isArray(rows) ? rows.find((x) => x && x.key === '$VodKey') : null; + if (row) item = { ...(row.meta || {}), key: row.key, resume: row.position || 0, duration: row.duration || 0 }; + } catch {} } - if (!item) item = (S.rows || []).flatMap((r) => r.items || []).find((x) => x && x.type !== 'live' && x.tmdbId); - if (!item) return { ok: false, error: 'no VOD item found', rows: S.rows ? S.rows.length : 0 }; + if (!item) return { ok: false, error: 'exact -VodKey was not found in rows, Watchlist, or watch state', key: '$VodKey', rows: S.rows ? S.rows.length : 0 }; const requestedResume = $VodResumeSeconds; const existingWatch = S.watchMap && item.key ? S.watchMap[item.key] : null; const duration = $VodDurationSeconds || +(existingWatch && existingWatch.duration) || +item.duration || 0; diff --git a/bench/cut-apk-release.ps1 b/bench/cut-apk-release.ps1 index 5b5a670..0976e94 100644 --- a/bench/cut-apk-release.ps1 +++ b/bench/cut-apk-release.ps1 @@ -116,7 +116,34 @@ $embeddedVersion = [regex]::Match($badging, "(?m)^package:.*\bversionName='([^'] if (-not $embeddedVersion.Success -or $embeddedVersion.Groups[1].Value -cne $ver) { throw "Release APK embedded versionName does not match package.json ($ver)." } -Write-Host "Verified APK embedded version and production signing certificate." -ForegroundColor Green +$embeddedPackage = [regex]::Match($badging, "(?m)^package:\s+name='([^']+)'") +$embeddedCode = [regex]::Match($badging, "(?m)^package:.*\bversionCode='([0-9]+)'") +if (-not $embeddedPackage.Success -or $embeddedPackage.Groups[1].Value -cne 'app.triboon.tv') { + throw 'Release APK package id is not app.triboon.tv.' +} +if (-not $embeddedCode.Success) { throw 'Release APK versionCode is missing.' } + +$stableApk = Join-Path ([System.IO.Path]::GetTempPath()) "triboon-current-stable-$PID.apk" +try { + Invoke-WebRequest -UseBasicParsing -Uri 'https://github.com/d1same/triboon/releases/latest/download/triboon.apk' -OutFile $stableApk + $stableCertOutput = Invoke-CheckedAndroidTool -Tool $apksigner -Arguments @('verify', '--verbose', '--print-certs', $stableApk) -Label 'Stable APK signature verification' + $stableCert = [regex]::Match($stableCertOutput, '(?im)certificate SHA-256 digest:\s*([0-9a-f]{64})\s*$') + if (-not $stableCert.Success -or $stableCert.Groups[1].Value.ToLowerInvariant() -ne $expectedReleaseCertSha256) { + throw 'Current stable APK does not match the pinned Triboon production signer.' + } + $stableBadging = Invoke-CheckedAndroidTool -Tool $aapt -Arguments @('dump', 'badging', $stableApk) -Label 'Stable APK badging inspection' + $stablePackage = [regex]::Match($stableBadging, "(?m)^package:\s+name='([^']+)'") + $stableCode = [regex]::Match($stableBadging, "(?m)^package:.*\bversionCode='([0-9]+)'") + if (-not $stablePackage.Success -or $stablePackage.Groups[1].Value -cne 'app.triboon.tv' -or -not $stableCode.Success) { + throw 'Current stable APK identity is invalid.' + } + if ([int64]$embeddedCode.Groups[1].Value -le [int64]$stableCode.Groups[1].Value) { + throw "Release APK versionCode $($embeddedCode.Groups[1].Value) must be greater than current stable $($stableCode.Groups[1].Value)." + } +} finally { + Remove-Item -LiteralPath $stableApk -Force -ErrorAction SilentlyContinue +} +Write-Host "Verified APK package, version, higher versionCode, and production signing certificate." -ForegroundColor Green $sizeMb = [math]::Round((Get-Item $apk).Length / 1MB, 1) Write-Host "Built $apk ($sizeMb MB)" -ForegroundColor Green diff --git a/bench/windows-client-smoke-server.js b/bench/windows-client-smoke-server.js new file mode 100644 index 0000000..1021bf4 --- /dev/null +++ b/bench/windows-client-smoke-server.js @@ -0,0 +1,196 @@ +'use strict'; + +// Local-only native Windows client harness. It exercises the real remote-origin bridge, HTTP +// ranges, non-zero Continue Watching, subtitles, an EOF-to-next handoff, and a 4K second item +// without requiring an owner's Triboon account or credentials. +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); + +const host = '127.0.0.1'; +const port = Number(process.env.TRIBOON_WINDOWS_SMOKE_PORT || 17888); +const media1080 = path.resolve(process.env.TRIBOON_WINDOWS_SMOKE_1080 || 'tmp/windows-smoke-1080.mp4'); +const media4k = path.resolve(process.env.TRIBOON_WINDOWS_SMOKE_4K || 'tmp/windows-smoke-4k.mp4'); +const duration = Math.max(4, Math.min(600, Number(process.env.TRIBOON_WINDOWS_SMOKE_DURATION || 8))); +const automate = process.env.TRIBOON_WINDOWS_SMOKE_AUTOMATE === '1'; +const events = []; + +function record(type, data) { + events.push({ type, at: Date.now(), ...(data && typeof data === 'object' ? data : {}) }); + if (events.length > 500) events.splice(0, events.length - 500); +} + +for (const file of [media1080, media4k]) { + if (!fs.existsSync(file)) throw new Error(`Missing smoke fixture: ${file}`); +} + +function sendFile(req, res, file, type) { + const size = fs.statSync(file).size; + const match = /^bytes=(\d*)-(\d*)$/i.exec(req.headers.range || ''); + if (!match) { + res.writeHead(200, { 'Content-Type': type, 'Content-Length': size, 'Accept-Ranges': 'bytes' }); + return fs.createReadStream(file).pipe(res); + } + const start = match[1] ? Math.min(size - 1, Number(match[1])) : 0; + const end = match[2] ? Math.min(size - 1, Number(match[2])) : size - 1; + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) { + res.writeHead(416, { 'Content-Range': `bytes */${size}` }); + return res.end(); + } + res.writeHead(206, { + 'Content-Type': type, + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + }); + return fs.createReadStream(file, { start, end }).pipe(res); +} + +const page = `Triboon Windows smoke + +

Triboon Windows native smoke

Waiting for the guarded Windows bridge...

+`; + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${host}:${port}`); + if (url.pathname === '/smoke-event' && req.method === 'POST') { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { if (body.length < 16384) body += chunk; }); + req.on('end', () => { + try { + const event = JSON.parse(body); + record(String(event.type || 'page'), event); + } catch { record('invalid-page-event'); } + res.writeHead(204).end(); + }); + return; + } + if (url.pathname === '/smoke-events') { + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + return res.end(JSON.stringify(events)); + } + if (url.pathname === '/api/server') { + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + return res.end(JSON.stringify({ version: '2.8.0', needsSetup: false })); + } + if (url.pathname === '/api/stream/smoke-1080') { + record('request-1080', { range: req.headers.range || '' }); + return sendFile(req, res, media1080, 'video/mp4'); + } + if (url.pathname === '/api/stream/smoke-4k') { + record('request-4k', { range: req.headers.range || '' }); + return sendFile(req, res, media4k, 'video/mp4'); + } + if (url.pathname === '/api/subtitle/smoke') { + res.writeHead(200, { 'Content-Type': 'text/vtt; charset=utf-8', 'Cache-Control': 'no-store' }); + const end = new Date((duration - 0.5) * 1000).toISOString().slice(11, 23); + return res.end('WEBVTT\n\n00:00:00.000 --> ' + end + '\nTriboon Windows subtitle smoke test\n'); + } + if (url.pathname === '/') { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; object-src 'none'", + 'Cache-Control': 'no-store', + }); + return res.end(page); + } + res.writeHead(404).end('not found'); +}); + +server.listen(port, host, () => { + console.log(`Triboon Windows smoke server: http://${host}:${port}`); + console.log(`1080p: ${media1080}`); + console.log(`4K: ${media4k}`); +}); diff --git a/clients/windows-px8/LIBMPV-LICENSE.LGPL b/clients/windows-px8/LIBMPV-LICENSE.LGPL new file mode 100644 index 0000000..d35234e --- /dev/null +++ b/clients/windows-px8/LIBMPV-LICENSE.LGPL @@ -0,0 +1,501 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! diff --git a/clients/windows-px8/LIBMPV-SOURCE.md b/clients/windows-px8/LIBMPV-SOURCE.md new file mode 100644 index 0000000..a8884b7 --- /dev/null +++ b/clients/windows-px8/LIBMPV-SOURCE.md @@ -0,0 +1,80 @@ +# libmpv source, license, and rebuild information + +Triboon for Windows dynamically links to an unmodified LGPL build of libmpv. +Triboon does not statically incorporate mpv or prevent users from replacing the +DLL with a compatible build. + +## Exact distributed runtime + +| Item | Locked value | +| --- | --- | +| Archive | `mpv-dev-lgpl-x86_64-20260713-git-e5486b96d7.7z` | +| Archive URL | `https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-13-e5486b96d7/mpv-dev-lgpl-x86_64-20260713-git-e5486b96d7.7z` | +| SHA-256 | `1016b6029da77f96e3a2831d2c33107eee43f798374ba90f56dce45717ed7932` | +| `libmpv-2.dll` SHA-256 | `93a3095997a4ae8028a5e772ef185600dd7b2bab5f3ba3f2d6d5c4e7d9f4bd91` | +| Release | `https://github.com/zhongfly/mpv-winbuild/releases/tag/2026-07-13-e5486b96d7` | +| mpv source | `https://github.com/mpv-player/mpv/commit/e5486b96d7d06dd148337899bfdc46bf25101663` | +| Builder workflow source | `https://github.com/zhongfly/mpv-winbuild/blob/b4b1088c30e8821e012fd20052de4c2d3a8eaad4/.github/workflows/mpv.yml` | +| Builder commit used by the published run | `b4b1088c30e8821e012fd20052de4c2d3a8eaad4` | +| Published build run | `https://github.com/zhongfly/mpv-winbuild/actions/runs/29253959401` | + +The selected archive is the baseline x86-64 LGPL variant, not the GPL or +x86-64-v3 variant. Its release records Clang as the compiler. The Triboon +workflow pins both its immutable URL and SHA-256 and verifies the digest before +using any file from the archive. + +The installer includes the verbatim license text as `LIBMPV-LICENSE.LGPL`. +mpv's copyright notices and the same LGPL text are also available in the exact +source tree: + +- `https://github.com/mpv-player/mpv/blob/e5486b96d7d06dd148337899bfdc46bf25101663/Copyright` +- `https://github.com/mpv-player/mpv/blob/e5486b96d7d06dd148337899bfdc46bf25101663/LICENSE.LGPL` + +The exact archive contains the mpv C headers, the MinGW import library +`libmpv.dll.a`, and one runtime binary, `libmpv-2.dll`. It does **not** contain +`mpv.def`, an MSVC `mpv.lib`, or separate FFmpeg/libass/libplacebo codec, +subtitle, or rendering DLLs. The Triboon package script derives `mpv.def` from +the locked DLL exports and generates `mpv.lib` with the installed MSVC tools; +neither generated link file is distributed. The single upstream DLL contains +the enabled LGPL-build components. Use the pinned builder workflow/run and its +package-version/source summary for those exact component revisions. + +`dumpbin /dependents` shows that this DLL imports standard Windows system +libraries and `vulkan-1.dll`. The installer intentionally does not copy a +Vulkan loader. A current x64 graphics driver/Vulkan Runtime must provide +`C:\Windows\System32\vulkan-1.dll`, even though Triboon requests mpv's D3D11 +renderer. Missing that loader prevents Windows from loading `libmpv-2.dll`. + +## Reproduce or modify the runtime + +For the closest reproduction of the distributed DLL: + +1. Check out `zhongfly/mpv-winbuild` at + `b4b1088c30e8821e012fd20052de4c2d3a8eaad4`. +2. Open `.github/workflows/mpv.yml` and run its `Build MPV` route with the same + inputs recorded by run `29253959401`: Clang compiler, `64` target, LGPL + enabled, no additional mpv pull-request patches, and mpv commit + `e5486b96d7d06dd148337899bfdc46bf25101663`. +3. That workflow checks out the pinned Windows build toolchain repository, + builds the dependency graph and mpv, and emits the + `mpv-dev-lgpl-x86_64-*` archive containing the headers, MinGW import + library, and `libmpv-2.dll` described above. +4. Verify the resulting dependency/source revision table in the workflow job + summary. A byte-identical archive is not guaranteed across a newer compiler, + operating-system image, or dependency mirror; corresponding behavior and + source are the reproducibility target. + +Alternatively, mpv's supported Meson route begins from the exact mpv commit and +uses `meson setup build -Dlibmpv=true`, `meson compile -C build`, and +`meson install -C build` after providing compatible FFmpeg, libplacebo, libass, +and other enabled dependencies. See mpv's `README.md` and `meson_options.txt` at +the exact commit for the authoritative options. + +## Use a replacement DLL + +Stop Triboon, back up the installed `libmpv-2.dll`, place an ABI-compatible x64 +replacement at the same path, and restart the application. Triboon performs no +signature or checksum enforcement on the installed replacement. Keep the +replacement's own notices, ensure its codec/profile support matches the media +you intend to play, and install every runtime DLL that replacement imports. +Reinstalling or upgrading Triboon restores the DLL shipped by that release. diff --git a/clients/windows-px8/README.md b/clients/windows-px8/README.md index 42d6df6..cbe25c6 100644 --- a/clients/windows-px8/README.md +++ b/clients/windows-px8/README.md @@ -1,96 +1,136 @@ -# Triboon PX8 — native Windows GPU client - -**Status: experimental preview, not a stable release asset.** The P1 WebView -shell has a dispatch-only CI build path and still uses web playback. A separate -`native_client=true` dispatch builds the libmpv feature experiment, but that is a -build artifact rather than proof of an integrated player. The web-to-native -bridge and end-to-end GPU/hardware behavior remain unverified. - -## Why PX8 exists (and why it is NOT just "the browser") - -On Windows the browser already is a capable Triboon client — Chrome/Edge hardware-decode -H.264/HEVC/VP9/AV1 through the GPU and play everything the web player supports. PX8's native -roadmap earns its keep only by adding things a browser cannot: - -- **True direct play** of the original file (no server remux) via libmpv. -- **HDR / Dolby Vision passthrough** to an HDR display. -- **Audio bitstream passthrough** — Dolby Atmos / TrueHD / DTS-HD MA sent untouched to an AVR - (browsers downmix to stereo; even the Android app re-encodes in most paths). -- **Precise seeking + frame-accurate playback** on any container, using the GPU. - -If those don't matter for a given setup, the WebView2 shell (or just a browser) is the lighter -answer. PX8 is for the home-theater tier. - -## Target architecture — reuse everything, hand off playback - -PX8 is a **Tauri v2** desktop app. It is deliberately thin, exactly like the Android TV shell: - -1. A first-run **connect screen** (`ui/connect.html`) captures the Triboon **server URL** (+ optional - Quick Connect code) and remembers it. One build works against any server — localhost, LAN, or - remote — chosen by the user (owner's decision). -2. The Tauri **WebView2** window then loads the Triboon web UI straight from that server - (`http:///`). All browsing, search, settings, Live TV, subtitles, Continue Watching — - the entire existing UI — is reused as-is. No UI is reimplemented. -3. The target JS **bridge** (`ui/bridge.js`) mirrors the Android `TriboonTV` - playback contract. The current bridge is intentionally disabled - (`nativeChromeVersion() === 0`, `playVideo() === false`), so the web UI does - not yet hand normal playback to libmpv. -4. The feature-gated Rust **libmpv** module can play a tokened URL from the - standalone native test. Integrating that surface with normal `/api/play`, - transport/progress callbacks, track selection, and web player chrome remains - P2 work. - -Target: Triboon's proven web UI plus a native GPU player, connected by a thin -bridge and using the existing server APIs. - -## Prerequisites (build machine) - -- **Rust** (stable) via [rustup](https://rustup.rs) → `rustc`, `cargo` -- **Tauri CLI v2**: `cargo install tauri-cli --version "^2"` (or `npm i -g @tauri-apps/cli`) -- **MSVC Build Tools** (Visual Studio Build Tools, "Desktop development with C++") -- **WebView2 Runtime** — preinstalled on Windows 10/11; the installer can bundle the bootstrapper -- **libmpv** — `libmpv-2.dll` + headers. Ship `libmpv-2.dll` next to the exe; point the - `libmpv2` crate at the import lib during build (see `src-tauri/build.rs`). - -## Build / run (once the toolchain is present) +# Triboon for Windows + +Triboon for Windows is the production x64 desktop client for Windows 10 and +Windows 11. It reuses the same server-hosted Triboon interface as the browser +and Android apps, but hands video playback to a persistent native libmpv +surface. The Windows server installer under `installer/windows/` is a separate +product. + +Public availability is a release outcome, not a source-code claim. A client is +publishable only after the native build, automated contracts, real playback +smokes, and the release checklist in `VERIFY.md` pass for the exact tag commit. + +## Runtime architecture + +1. The bundled connect page accepts and validates a Triboon server origin. + Only the origin is persisted; credentials and stream tokens are not stored + by the desktop shell. +2. WebView2 loads the normal Triboon web UI from that exact origin. Browsing, + search, settings, source selection, Continue Watching, and guide state stay + owned by the shared web application. +3. A narrow `window.TriboonTV` bridge is injected only for the configured + Triboon origin. The remote page does not receive the general Tauri API. +4. VOD or Live TV playback opens the dedicated native player window. One + long-lived libmpv owner handles play, pause, seek, source replacement, + subtitles, audio tracks, progress, buffering, and terminal events. Episode + replacement reuses that surface so show details never flash between + episodes. +5. Every callback carries the current playback token. Stale progress, error, + close, subtitle, and next-episode events from an old item are ignored by the + shared web state machine. + +The bridge accepts only schema-bounded commands and media/subtitle URLs owned +by the configured Triboon server. Logs and errors must redact query strings so +`?t=` and other credentials cannot leak into support output. + +## GPU playback contract + +The client uses mpv's D3D11 renderer and requests safe automatic hardware +decoding. NVIDIA, AMD, and Intel decoding are all supported when the installed +driver exposes a compatible decoder; unsupported codecs or profiles fall back +to software instead of failing playback. + +Requesting hardware decoding is not proof that it is active. The diagnostics +panel must report mpv's runtime decoder state (including `hwdec-current`), video +codec, renderer, dimensions, dropped frames, position, and buffer. A test may +claim GPU decode only when `hwdec-current` reports a real hardware path while +frames advance. CI can prove the native client compiles and packages; it cannot +prove a GitHub runner's virtual display used a physical decoder. + +HDR output is automatic where the GPU, driver, display, operating-system HDR +setting, codec, and source all support it. Dolby Vision and lossless audio +passthrough are hardware-chain dependent and are never universal guarantees. +Bitstream passthrough is opt-in; the safe default is decoded PCM audio. + +The pinned libmpv build imports the Windows Vulkan loader (`vulkan-1.dll`) even +when Triboon renders through D3D11. A current NVIDIA, AMD, or Intel graphics +driver (or its matching Vulkan Runtime) must therefore provide +`C:\Windows\System32\vulkan-1.dll`. Triboon does not bundle a copy of the +driver/runtime loader. If it is absent, update the graphics driver before +installing the client; the application cannot load libmpv without it. + +## Playback parity + +The Windows release gate covers the same shared contracts as Android: + +- direct play, remux, then transcode fallback; +- prepared-source reuse and fast first frame; +- pause/resume, quiet seeks/skips, buffering recovery, and source retry; +- accurate resume and final Continue Watching checkpoints; +- token-safe manual/autoplay next episode with no details-page flash; +- quality, audio, subtitle version/sync/size, and episode selection; +- native HLS/TS Live TV with ordered server fallback and rapid retuning; +- mouse, keyboard, media keys, fullscreen, D-pad/controller, Back, and guide + behavior. + +See `docs-player-regression-map.md` P15 and `VERIFY.md` for the normative and +live acceptance checks. + +## Build locally + +Required on Windows x64: + +- Node.js 24 and `npm`; +- Rust stable for `x86_64-pc-windows-msvc`; +- Visual Studio 2022 Build Tools with Desktop development with C++; +- the WebView2 Runtime; +- 7-Zip (used to inspect the NSIS payload after the build). + +Local and CI builds use the same fail-closed script. It downloads the immutable +LGPL libmpv archive, verifies both its archive and DLL SHA-256, generates the +MSVC import library, records the locked Rust dependency/license inventory, runs +the Rust tests, builds the NSIS installer, extracts that installer with 7-Zip, +checks the embedded application metadata, and byte-compares every required +runtime/legal resource against the staged inputs. Tauri intentionally patches +bundle metadata into the embedded executable, so that file is checked by unique +name, size, x64 PE machine type, and product/file version instead of against the +pre-bundle hash. ```powershell -cd clients\windows-px8 -npm ci # locked frontend dev deps (@tauri-apps/cli, @tauri-apps/api) -npm run tauri dev # run against a dev build -npm run tauri build # → src-tauri\target\release\ + an MSI/NSIS installer +# From the repository root. A normal PowerShell window is sufficient; the +# script imports the installed MSVC x64 environment itself. +powershell -ExecutionPolicy Bypass -File .\clients\windows-px8\scripts\build-package.ps1 + +# A release build additionally creates the versioned alias and rejects a tag +# that does not match package/Cargo/Tauri version 2.8.0. +powershell -ExecutionPolicy Bypass -File .\clients\windows-px8\scripts\build-package.ps1 -Tag v2.8.0 +``` + +The default output is `dist\windows-client\Triboon-Windows-Client.exe`; a tag +build also emits `Triboon-Windows-Client-vX.Y.Z.exe` and proves the pair is +byte-identical. Use `-CacheDirectory` and `-ArtifactDirectory` to relocate only +those two build outputs. `-SkipTests` exists for packaging diagnostics, never +for a release. + +The public installer is currently unsigned unless the owner supplies a trusted +Windows code-signing certificate through protected CI secrets. Windows may show +a SmartScreen warning for an unsigned release. Never place a certificate, +private key, or password in this repository. + +## Distribution and LGPL replacement + +The release publishes byte-identical versioned and stable installer names: + +```text +Triboon-Windows-Client-vX.Y.Z.exe +Triboon-Windows-Client.exe ``` -The default output is the web-playback preview. CI publishes PX8 only as a -manual workflow artifact; do not attach it to a stable release until the native -bridge and hardware matrix are complete. The Windows *server* installer remains -a separate product under `installer\windows\`. - -## Roadmap (phased — each phase is independently shippable) - -- **P0 — scaffold (done)**: project skeleton, connect screen, bridge contract, - libmpv module boundary, and build docs. -- **P1 — window + connect + web UI**: Tauri window loads the server's Triboon UI after the connect - screen and remembers the server; no native player yet, so playback stays in the - page `