mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka
# Conflicts: # CLAUDE.md # libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
commit
5b2dd3b8b0
425 files changed
+59216
-3432
No files matched your search
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: stalker-portal
|
||||
description: Repository guidance for Stalker/Ministra portal catalogs, VOD/series shapes, playback metadata, collections, EPG, and remote control.
|
||||
---
|
||||
|
||||
# Stalker Portal
|
||||
|
||||
Use this skill when changing Stalker/Ministra routes, stores, catalog/detail
|
||||
views, playback, favorites/recent activity, EPG, or remote control.
|
||||
|
||||
## Read First
|
||||
|
||||
- `docs/architecture/stalker-portal.md`
|
||||
- `docs/architecture/stalker-epg.md` for ITV EPG work
|
||||
- `docs/architecture/remote-control.md` for live remote-control work
|
||||
|
||||
## Ownership
|
||||
|
||||
- Feature UI: `libs/portal/stalker/feature/src/lib/`
|
||||
- Store/API data access: `libs/portal/stalker/data-access/src/lib/`
|
||||
- Electron requests: `apps/electron-backend/src/app/events/stalker.events.ts`
|
||||
- Shared Stalker item normalization:
|
||||
`libs/shared/interfaces/src/lib/stalker-item.normalizer.ts`
|
||||
- Dashboard aggregation: `libs/workspace/dashboard/data-access/src/lib/`
|
||||
|
||||
Keep provider-specific API and normalization behavior in Stalker data access.
|
||||
Keep shared portal layouts/utilities provider-neutral. Preserve full-portal
|
||||
session auth and simple IPC request paths.
|
||||
|
||||
## `is_series` Cross-Surface Checklist
|
||||
|
||||
Treat VOD items with `is_series` as series across every downstream surface.
|
||||
Do not stop after making the detail view render.
|
||||
|
||||
1. Accept portal flags `true`, `1`, and `'1'` through the existing normalizers.
|
||||
Preserve all three modes: regular `/series`, embedded VOD `series[]`, and
|
||||
lazy Ministra VOD `is_series`.
|
||||
2. Build quick-start state through the shared series utility. Preserve
|
||||
`labelKey`, `labelParams`, and `episodeLabel` when adapting it for Stalker;
|
||||
translation parameters must reach the template.
|
||||
3. Preserve `is_series` and the VOD origin in favorites/recent activity.
|
||||
`extractStalkerItemType()` must normalize that activity to dashboard type
|
||||
`series`.
|
||||
4. Before either inline or external episode playback, persist the parent
|
||||
`seriesXtreamId` plus resolved `seasonNumber` and `episodeNumber`. Keep
|
||||
generated episode tracking IDs stable for lazy `is_series` episodes. When
|
||||
`season_number` is absent, derive the coordinate from the same naturally
|
||||
ordered season list used by quick start; do not default every season to 1.
|
||||
5. The dashboard reads saved playback positions; it must not infer episode
|
||||
numbers from provider payloads. Legacy rows without season/episode metadata
|
||||
remain badge-less until that episode is played again.
|
||||
|
||||
## Regression Coverage
|
||||
|
||||
- Series view/UI and playback handoff:
|
||||
`pnpm nx test portal-stalker-feature`
|
||||
- Stalker shape/store behavior:
|
||||
`pnpm nx test portal-stalker-data-access`
|
||||
- Dashboard classification and position lookup:
|
||||
`pnpm nx test workspace-dashboard-data-access`
|
||||
- Dashboard badge rendering when changed:
|
||||
`pnpm nx test workspace-dashboard-feature`
|
||||
|
||||
For user-visible workflow changes, run the closest available E2E target. If no
|
||||
fixture covers the affected portal shape, record that gap and perform the
|
||||
strongest targeted unit/build validation available.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
||||
name: Cleanup PR Draft Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
# contents: write — delete the draft release; actions: write — cancel the
|
||||
# closed PR's still-running build workflow before deleting.
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
delete-draft:
|
||||
name: Delete PR draft release
|
||||
# Fork PRs never get a draft (the release job skips them) and their
|
||||
# GITHUB_TOKEN is read-only regardless of the permissions block, so
|
||||
# there is nothing to cancel or delete.
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# A closed PR can be reopened while this job is still queued or
|
||||
# waiting; a reopened PR's fresh build must not be cancelled and
|
||||
# its draft must not be deleted. Check the live state up front
|
||||
# (and again right before deleting below).
|
||||
- name: Check PR is still closed
|
||||
id: pr-state
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# A build for this PR may still be running and would recreate the
|
||||
# rolling draft after we delete it. Cancel those runs (dead work
|
||||
# for a closed PR anyway) and wait for them to wind down. The
|
||||
# release job additionally re-checks the live PR state, so this
|
||||
# wait is defense in depth, not the only guard.
|
||||
- name: Cancel in-progress builds for the closed PR
|
||||
if: steps.pr-state.outputs.state == 'closed'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# --event pull_request: a manually dispatched build on the
|
||||
# same branch is not this PR's work and must not be cancelled.
|
||||
list_active_runs() {
|
||||
gh run list --repo "${GITHUB_REPOSITORY}" \
|
||||
--workflow 'Build and Make Electron App' \
|
||||
--branch "${HEAD_BRANCH}" \
|
||||
--event pull_request \
|
||||
--json databaseId,status \
|
||||
--jq '.[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "requested" or .status == "pending") | .databaseId'
|
||||
}
|
||||
|
||||
for run_id in $(list_active_runs); do
|
||||
echo "Cancelling run ${run_id}"
|
||||
gh run cancel "${run_id}" --repo "${GITHUB_REPOSITORY}" || true
|
||||
done
|
||||
|
||||
# Cancellation is asynchronous; poll until the runs settle.
|
||||
for _ in $(seq 1 18); do
|
||||
if [ -z "$(list_active_runs)" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Draft releases have no real git tag, so a lookup via
|
||||
# releases/tags/<tag> returns 404. List releases and match the
|
||||
# draft by its stored tag_name (test-pr-<n>) instead. The PR state
|
||||
# is re-checked one last time right before deleting, in case the
|
||||
# PR was reopened during the cancellation wait above.
|
||||
- name: Delete draft release for closed PR
|
||||
if: steps.pr-state.outputs.state == 'closed'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.state')" != "closed" ]; then
|
||||
echo "PR #${PR_NUMBER} was reopened; keeping its draft."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
|
||||
--jq ".[] | select(.draft and .tag_name == \"test-pr-${PR_NUMBER}\") | .id" |
|
||||
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/{}"
|
||||
@@ -109,5 +109,7 @@ jobs:
|
||||
push: ${{ steps.docker-meta.outputs.publish == 'true' }}
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
platforms: ${{ steps.docker-meta.outputs.platforms }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
@@ -0,0 +1,269 @@
|
||||
name: Publish Snap after public release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-snap:
|
||||
name: Verify public-release Snap assets
|
||||
if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz
|
||||
outputs:
|
||||
receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }}
|
||||
|
||||
steps:
|
||||
- name: Checkout released tooling
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install release source verifier
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
binutils \
|
||||
squashfs-tools \
|
||||
xz-utils
|
||||
|
||||
- name: Select exact public release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh api \
|
||||
--paginate \
|
||||
--slurp \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \
|
||||
> "${RUNNER_TEMP}/snap-release-assets.json"
|
||||
node tools/packaging/release-snap-assets.cjs select \
|
||||
--assets-json "${RUNNER_TEMP}/snap-release-assets.json" \
|
||||
--output-json "${RUNNER_TEMP}/selected-snap-release-assets.json"
|
||||
|
||||
- name: Download exact public release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads"
|
||||
rm -rf "${ASSET_DIRECTORY}"
|
||||
mkdir -p "${ASSET_DIRECTORY}"
|
||||
node -e \
|
||||
"const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \
|
||||
"${RUNNER_TEMP}/selected-snap-release-assets.json" |
|
||||
while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do
|
||||
gh api \
|
||||
--header "Accept: application/octet-stream" \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \
|
||||
> "${ASSET_DIRECTORY}/${ASSET_NAME}"
|
||||
done
|
||||
|
||||
- name: Verify downloaded public release assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERIFIED_ASSET_STAGING="${RUNNER_TEMP}/verified-snap-release-assets"
|
||||
SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
|
||||
SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
|
||||
test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}"
|
||||
test ! -e "${VERIFIED_ASSET_STAGING}"
|
||||
sudo test ! -e "${SEALED_ASSET_PARENT}"
|
||||
node tools/packaging/release-snap-assets.cjs verify \
|
||||
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
|
||||
--directory "${RUNNER_TEMP}/snap-release-downloads" \
|
||||
--repository-revision "$(git rev-parse HEAD)" \
|
||||
--verified-directory "${VERIFIED_ASSET_STAGING}"
|
||||
sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
|
||||
sudo mv "${VERIFIED_ASSET_STAGING}" "${SEALED_ASSET_DIRECTORY}"
|
||||
sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
|
||||
sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
|
||||
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
|
||||
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
|
||||
|
||||
- name: Reverify sealed public release assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
|
||||
node tools/packaging/release-snap-assets.cjs verify-sealed \
|
||||
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
|
||||
--directory "${VERIFIED_ASSET_DIRECTORY}" \
|
||||
--receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \
|
||||
--repository-revision "$(git rev-parse HEAD)"
|
||||
|
||||
- name: Bind verified release transfer
|
||||
id: bind-transfer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"
|
||||
RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
|
||||
RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
|
||||
[[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
|
||||
printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Transfer verified release assets
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: verified-snap-release-assets
|
||||
path: /var/lib/iptvnator-snap-release/assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
include-hidden-files: true
|
||||
|
||||
publish-snap:
|
||||
name: Publish verified public-release Snap to edge
|
||||
needs: verify-snap
|
||||
if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Download verified release assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: verified-snap-release-assets
|
||||
path: ${{ runner.temp }}/verified-snap-release-assets
|
||||
|
||||
- name: Seal transferred public release assets
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"
|
||||
SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
|
||||
SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
|
||||
test -d "${TRANSFERRED_ASSET_DIRECTORY}"
|
||||
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"
|
||||
shopt -s nullglob dotglob
|
||||
TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)
|
||||
TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)
|
||||
test "${#TRANSFERRED_SNAPS[@]}" -gt 0
|
||||
test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"
|
||||
test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
|
||||
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
|
||||
test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
|
||||
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
|
||||
for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do
|
||||
test -f "${ASSET_FILE}"
|
||||
test ! -L "${ASSET_FILE}"
|
||||
done
|
||||
RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
|
||||
RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
|
||||
ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
|
||||
[[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
|
||||
test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"
|
||||
/usr/bin/jq --exit-status '
|
||||
type == "object" and
|
||||
(keys == ["assets", "repositoryRevision", "schemaVersion"]) and
|
||||
(.schemaVersion == 1) and
|
||||
(.repositoryRevision |
|
||||
type == "string" and test("^[a-f0-9]{40,64}$")) and
|
||||
(.assets | type == "array" and length >= 2) and
|
||||
(.assets | all(.[];
|
||||
type == "object" and
|
||||
(keys == ["id", "name", "sha256", "size"]) and
|
||||
(.id |
|
||||
type == "number" and . > 0 and
|
||||
. <= 9007199254740991 and . == floor) and
|
||||
(.name |
|
||||
type == "string" and length > 0 and
|
||||
. != "." and . != ".." and
|
||||
(contains("/") | not) and
|
||||
(contains("\\") | not) and
|
||||
(explode | all(.[]; . > 31 and . != 127))) and
|
||||
(.sha256 |
|
||||
type == "string" and test("^[a-f0-9]{64}$")) and
|
||||
(.size |
|
||||
type == "number" and . > 0 and
|
||||
. <= 9007199254740991 and . == floor))) and
|
||||
([.assets[].name] | length == (unique | length)) and
|
||||
([.assets[] |
|
||||
select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |
|
||||
length == 1) and
|
||||
([.assets[] | select(.name | endswith(".snap"))] |
|
||||
length >= 1) and
|
||||
(.assets | all(.[];
|
||||
.name == "linux-frame-copy-runtime-sources.tar.xz" or
|
||||
(.name | endswith(".snap"))))
|
||||
' "${RECEIPT_PATH}" > /dev/null
|
||||
RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")"
|
||||
test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"
|
||||
SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"
|
||||
CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"
|
||||
umask 077
|
||||
/usr/bin/jq --raw-output \
|
||||
'.assets[] | [.name, (.size | tostring)] | @tsv' \
|
||||
"${RECEIPT_PATH}" > "${SIZE_MANIFEST}"
|
||||
while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do
|
||||
ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"
|
||||
ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"
|
||||
test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"
|
||||
done < "${SIZE_MANIFEST}"
|
||||
/usr/bin/jq --raw-output \
|
||||
'.assets[] | "\(.sha256) \(.name)"' \
|
||||
"${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"
|
||||
(
|
||||
cd "${TRANSFERRED_ASSET_DIRECTORY}"
|
||||
/usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"
|
||||
)
|
||||
rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"
|
||||
shopt -u nullglob dotglob
|
||||
sudo test ! -e "${SEALED_ASSET_PARENT}"
|
||||
sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
|
||||
sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"
|
||||
sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
|
||||
sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
|
||||
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
|
||||
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
|
||||
|
||||
- name: Install Snapcraft
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
sudo snap install snapcraft --classic --channel=stable
|
||||
|
||||
- name: Publish all public-release snaps to edge
|
||||
shell: bash
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
|
||||
STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"
|
||||
unset SNAPCRAFT_STORE_CREDENTIALS
|
||||
shopt -s nullglob dotglob
|
||||
SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)
|
||||
test "${#SNAP_FILES[@]}" -gt 0
|
||||
for SNAP_FILE in "${SNAP_FILES[@]}"; do
|
||||
SNAP_NAME="${SNAP_FILE##*/}"
|
||||
echo "Publishing public release asset: ${SNAP_NAME}"
|
||||
# Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.
|
||||
# GitHub Actions never promotes automatically.
|
||||
SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"
|
||||
done
|
||||
unset STORE_CREDENTIALS
|
||||
shopt -u nullglob dotglob
|
||||
@@ -84,4 +84,9 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
|
||||
vendor/embedded-mpv/*/bin/
|
||||
vendor/embedded-mpv/*/include/
|
||||
vendor/embedded-mpv/*/lib/
|
||||
vendor/embedded-mpv/*/notices/
|
||||
vendor/embedded-mpv/*/runtime-manifest.json
|
||||
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
@@ -0,0 +1,90 @@
|
||||
# Linux Embedded MPV Frame-Copy Packaging Plan
|
||||
|
||||
## Audited baseline
|
||||
|
||||
- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame
|
||||
reader addon, shared controls, native-view fallback, and runtime capability
|
||||
probes.
|
||||
- Existing packaged Linux builds intentionally remove the helper/runtime and
|
||||
retain only system `mpv --wid` native-view.
|
||||
- Electron, Electron libraries, `embedded_mpv.node`, and
|
||||
`embedded_mpv_frame_reader.node` must never load or link libmpv. Only the
|
||||
helper may link it.
|
||||
- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak
|
||||
Linux targets. The available reproducible native/runtime toolchain is x64.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64
|
||||
artifact marker-only and fail closed to native-view; never accept an
|
||||
architecture override that injects x64 native files.
|
||||
2. Use three isolated packaging profiles:
|
||||
- `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies
|
||||
and contain no private `native/lib`.
|
||||
- `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library
|
||||
closure with `$ORIGIN`-relative helper loading.
|
||||
- `flatpak`: Flatpak contains the same pinned closure, validated in the
|
||||
exact `/app` runtime context.
|
||||
3. Treat the package manifest as necessary but insufficient. Frame-copy is
|
||||
available only after exact manifest/schema/profile checks, executable-mode
|
||||
checks, artifact hashes, dependency-closure/process-isolation checks, and a
|
||||
bounded helper runtime probe. No environment flag bypasses this gate.
|
||||
4. Publish exact source archives, recursive source identities, build flags,
|
||||
licenses, notices, patches/tooling, and pinned display data for bundled
|
||||
runtimes. Bind every bundled x64 package manifest to the final compliance
|
||||
archive bytes and released repository revision.
|
||||
5. Keep Snap Store credentials isolated from release-tag code on a fresh
|
||||
runner. Store publication is edge-only; candidate/stable promotion remains
|
||||
manual after installed-package smoke.
|
||||
|
||||
## TDD implementation phases
|
||||
|
||||
1. Add failing tests for target/profile partitioning, x64 and marker-only
|
||||
layouts, exact dependency declarations, RPATH/SONAME rules, executable
|
||||
modes, and Electron/libmpv isolation.
|
||||
2. Implement profile-aware build and packaging hooks that stage the helper,
|
||||
frame reader, runtime manifest, private closure where applicable, and legal
|
||||
payload without weakening native-view.
|
||||
3. Add failing runtime-policy tests for missing/tampered files, wrong
|
||||
architecture/profile, malformed manifests, loader failures, hostile
|
||||
environments, probe timeout/output bounds, and stable fallback reasons.
|
||||
4. Implement one sanitized helper environment shared by probe and playback,
|
||||
including Snap graphics-provider handling and Flatpak runtime paths.
|
||||
5. Add failing compliance/release tests for exact recursive submodule records,
|
||||
VCS-free source inventory, archive member/type layout, source checksums,
|
||||
license/notices completeness, package-to-source byte binding, sealed asset
|
||||
receipts, and credential boundaries.
|
||||
6. Implement deterministic source generation, package bindings, static Snap
|
||||
inspection, fresh-runner artifact transfer, and minimal direct Store
|
||||
upload.
|
||||
7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime
|
||||
native-view fallback. Run fixture-contract tests before the smoke and allow
|
||||
CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime
|
||||
gate.
|
||||
8. Update canonical architecture/maintenance documentation and mirrored
|
||||
`AGENTS.md`/`CLAUDE.md` contracts.
|
||||
|
||||
## Acceptance and verification matrix
|
||||
|
||||
- Local/macOS:
|
||||
- Nx discovery
|
||||
- packaging and Electron backend unit/integration tests
|
||||
- packaged-smoke fixture tests
|
||||
- affected lint targets
|
||||
- production backend build
|
||||
- formatting, syntax, and `git diff --check`
|
||||
- Linux x64 CI:
|
||||
- build the pinned runtime/helper/frame reader
|
||||
- verify helper links/resolves libmpv and Electron/addons do not
|
||||
- extract and statically validate all six package families
|
||||
- run system, portable, Snap-installed, and Flatpak application probes
|
||||
- run packaged frame-copy playback and missing-runtime native-view fallback
|
||||
- regenerate and bind the exact compliance source archive
|
||||
- Non-x64 CI:
|
||||
- build selected ARM package targets independently
|
||||
- require marker-only layout and absence of every x64 native/runtime artifact
|
||||
- Merge gate:
|
||||
- exact-head CI green
|
||||
- no unresolved review findings
|
||||
- fresh code review clean
|
||||
- no automatic Snap promotion beyond edge
|
||||
@@ -70,14 +70,12 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
|
||||
- GPU/compositor debugging:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
|
||||
```
|
||||
- Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
`console.*`; never log raw credentials while debugging.
|
||||
|
||||
- If local Nx state gets weird before a rerun:
|
||||
|
||||
@@ -134,6 +132,13 @@ Key files:
|
||||
engine-neutral `PlayerController` contract, standalone
|
||||
`app-player-controls`, generic web-video adapter/helper, and component-scoped
|
||||
`WEB_PLAYER_SHARED_CONTROLS` rollout token.
|
||||
- In fullscreen, `app-player-controls` shows a pointer-transparent media-title
|
||||
overlay at the top while controls are revealed (`mediaTitle` input:
|
||||
movie/channel/series name, plus an `S01E03` second line for episodes). Series
|
||||
names flow from the Xtream/Stalker detail views through
|
||||
`PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;
|
||||
movie and live hosts fall back to `playback.title`, skipping raw stream-URL
|
||||
fallbacks. Outside fullscreen the overlay stays hidden.
|
||||
- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox
|
||||
appears only when HTML5, Video.js, or ArtPlayer is selected.
|
||||
`WebPlayerViewComponent` snapshots the preference into
|
||||
@@ -222,6 +227,147 @@ Key files:
|
||||
- Canonical docs: `docs/architecture/player-controls-contract.md` and
|
||||
`docs/architecture/embedded-mpv-native.md`
|
||||
|
||||
## Linux Embedded MPV Packaging
|
||||
|
||||
- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,
|
||||
Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain
|
||||
marker-only and must never inherit x64 native artifacts from environment
|
||||
overrides.
|
||||
- Packaging runs three isolated profiles:
|
||||
- `system`: DEB/RPM/Pacman, no private `native/lib`, with package
|
||||
dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,
|
||||
RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
|
||||
Pacman=`mpv,libglvnd,mesa`
|
||||
- `portable`: AppImage/Snap with the pinned LGPL-compatible closure
|
||||
- `flatpak`: Flatpak with the same pinned closure
|
||||
- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real
|
||||
Electron ELF so Electron Builder's `electron-wrapper` passes it directly to
|
||||
Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and
|
||||
`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.
|
||||
- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04
|
||||
provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the
|
||||
package dependency or advertising frame-copy without a compatible runtime.
|
||||
- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,
|
||||
Electron libraries, `embedded_mpv.node`, and
|
||||
`embedded_mpv_frame_reader.node` must not load or link it. Preserve this
|
||||
process-isolation contract in build, package, and smoke checks.
|
||||
- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`
|
||||
exclusively writes the profile-normalized unpacked native tree. Layout and
|
||||
final-artifact checks must reject every archived
|
||||
`/electron-backend/native/**` entry so system and marker-only packages cannot
|
||||
hide stale x64 artifacts.
|
||||
- Packaged addon, frame-reader, and helper discovery is package-owned
|
||||
`app.asar.unpacked` only. Writable cwd/dist candidates are development-only
|
||||
and must never satisfy packaged native-view support or the frame-copy gate.
|
||||
- Pristine afterPack/unpacked layouts scan Electron libraries recursively.
|
||||
Extracted Snap payloads exclude only the package-manager `lib/**` and
|
||||
`usr/lib/**` trees that Snap overlays into the same root; every other
|
||||
directory remains recursive, and Electron-library symlinks still fail
|
||||
closed.
|
||||
- Linux frame-copy availability is fail-closed. The packaged manifest,
|
||||
artifact modes, declared bundled hashes/closure, and bounded
|
||||
`--runtime-probe` must all succeed before frame-copy can relax the renderer
|
||||
sandbox. Any failure reports a stable reason and falls back to native-view
|
||||
without crashing; an environment flag never bypasses this gate.
|
||||
- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus
|
||||
the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,
|
||||
with `mesa-core22` as default provider. It declares only the canonical
|
||||
provider layouts: `/usr/share/libdrm` binds from
|
||||
`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
|
||||
`$SNAP/graphics/drirc.d`. The provider is external shared content, not part
|
||||
of IPTVnator's package size, source archive, or notices. Installed-Snap CI
|
||||
must prove controlled unavailable exit after disconnect, then reconnect and
|
||||
prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`.
|
||||
- The probe and playback helper share one sanitized loader environment:
|
||||
ambient audit, preload, library, graphics-driver, and shell-startup overrides
|
||||
are removed; the validated private closure wins; trusted Snap GL,
|
||||
`graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots
|
||||
precede generic in-snap roots. The core22 base must precede GNOME so its
|
||||
`libedit.so.2` cannot be replaced by the older copy requiring
|
||||
`libtinfo.so.5`. The extracted-artifact verifier removes the identical
|
||||
unsafe loader/graphics/shell set before direct helper smoke while preserving
|
||||
feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the
|
||||
wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches
|
||||
probe/playback through the regular executable
|
||||
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
|
||||
disconnected provider returns `snap-graphics-provider-unavailable` before
|
||||
helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch
|
||||
runs the complete cached manifest/hash/helper gate before BrowserWindow
|
||||
startup and exits with one availability JSON line. A nonzero helper exit
|
||||
keeps top-level reason `helper-probe-failed`; `helperReason` is present only
|
||||
for an exact protocol-v1 line carrying a fixed allowlisted reason, and its
|
||||
optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid
|
||||
detail suppresses both helper fields. Every probe uses an explicit 16 MiB
|
||||
aggregate captured-output ceiling independent of tracing. With
|
||||
`IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted
|
||||
separately as one JSON-escaped stderr line whose `stderr` field is limited
|
||||
to 16,384 characters and whose `truncated` field is always explicit;
|
||||
trace-write failure cannot change the capability result. Installed-Snap CI
|
||||
enables Mesa EGL/GL diagnostics through this bounded channel. Any loader
|
||||
failure remains a stable native-view fallback, never a flag-enabled success.
|
||||
- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop
|
||||
Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL
|
||||
extension loader path comes from the sandbox cache. Flatpak CI must invoke
|
||||
the application-level `--embedded-mpv-runtime-probe`, not a direct helper
|
||||
probe that bypasses capability detection.
|
||||
- The packaged x64 Playwright smoke runs its fixture-contract target first and
|
||||
passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.
|
||||
This launch-only flag does not bypass the manifest, hash, loader, or helper
|
||||
capability gate; `--no-sandbox` remains root-only.
|
||||
- Bundled Linux releases must publish the exact source archives/git records,
|
||||
checksums, licenses, flags, patches, build scripts, and the pinned hwdata
|
||||
`pnp.ids` input. Each bundled package carries
|
||||
`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact
|
||||
`licenses/**` files. CI may cache immutable source inputs, but regenerates
|
||||
notices and a VCS-metadata-free
|
||||
`linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every
|
||||
run while retaining the exact pinned six recursive libplacebo submodule
|
||||
records. Each record is canonical `full-commit safe/path`; clone-depth
|
||||
dependent `git describe` annotations are discarded and never form part of
|
||||
the provenance identity. Its source index carries the globally sorted libplacebo
|
||||
directory/file/symlink inventory; file hashes, sizes, executable bits, link
|
||||
targets, aggregates, and canonical tree digest must match the trusted pinned
|
||||
checkout. The archive has an exact member/type layout and its
|
||||
`metadata/archive-sha256.txt` records must match the actual source archives.
|
||||
Concatenated tar/xz streams are inspected past every end marker. The final
|
||||
archive's SHA-256 and repository revision are copied into every bundled x64
|
||||
package manifest; system and marker-only packages carry no source-archive
|
||||
binding.
|
||||
Automated Snap Store publication is allowed only after a public `v*` GitHub
|
||||
release contains both the Snap assets and exactly one matching source
|
||||
archive. Before any upload, the workflow hashes and inspects that archive,
|
||||
verifies its exact member/type set and size bounds, clean tag revision,
|
||||
pinned sources including the six recursive submodule records and exact
|
||||
libplacebo tree digest, legal files, and exact released tooling, then
|
||||
performs bounded extraction and static package validation for every Snap.
|
||||
That public-release boundary independently revalidates the exact strict
|
||||
`meta/snap.yaml` graphics/shared-memory contract and enumerates
|
||||
`resources/app.asar`, rejecting any archived
|
||||
`electron-backend/native/**` payload before publication. Its bounded ASAR
|
||||
header reader uses only Node built-ins and released local tooling, so the
|
||||
clean tag checkout does not require `node_modules`.
|
||||
Exactly one x64 Snap must have matching
|
||||
`sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain
|
||||
marker-only. Checkout and the artifact-transfer actions are pinned to full
|
||||
commits; checkout does not persist credentials, and repository credentials
|
||||
are limited to download steps. A secretless verification job copies assets
|
||||
through no-follow descriptors, checks pre/post hashes, writes an exact
|
||||
receipt, repeats the complete source/package verification on a root-owned
|
||||
read-only snapshot, and transfers only that data through the pinned artifact
|
||||
service while its receipt digest travels separately through a job output.
|
||||
The dependent publish job runs on a bounded `ubuntu-latest` runner with no
|
||||
checkout or release-tag code, verifies that digest plus the exact receipt,
|
||||
asset hashes, and file-only layout, root-seals the data again, and installs
|
||||
Snapcraft directly. Store credentials exist only in its final fixed shell
|
||||
step, which resolves no PATH command, executes no released code, and exposes
|
||||
the credential only to each exact
|
||||
`/snap/bin/snapcraft upload --release=edge` process.
|
||||
Candidate/stable promotion is manual after installed-Snap frame-copy and
|
||||
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
|
||||
Canonical maintenance docs:
|
||||
`docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
|
||||
## Repo Skills
|
||||
|
||||
- `iptvnator-ui-design`
|
||||
@@ -244,6 +390,11 @@ Key files:
|
||||
Use when moving heavy database work off the main thread, adding worker-backed SQLite operations, or wiring loading/progress UI for Xtream and playlist DB flows.
|
||||
File: `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`
|
||||
|
||||
- `stalker-portal`
|
||||
Repository-specific guidance for Stalker/Ministra catalogs, all three VOD/series modes, cross-surface `is_series` behavior, playback metadata, collections, EPG, and remote control.
|
||||
Use when changing Stalker routes, stores, detail views, playback, favorites/recent activity, EPG, or remote control.
|
||||
File: `.codex/skills/stalker-portal/SKILL.md`
|
||||
|
||||
- `xtream-electron`
|
||||
Repository-specific guidance for IPTVnator's Electron-first Xtream implementation, including feature/data-access boundaries, worker-backed DB flows, and Xtream loading/progress UX expectations.
|
||||
Use when working on Xtream routes, store/data-source logic, or Electron-backed Xtream import/search/delete behavior.
|
||||
|
||||
@@ -127,14 +127,12 @@ Useful narrower flags:
|
||||
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
|
||||
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
|
||||
For GPU/compositor debugging:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 nx serve electron-backend
|
||||
```
|
||||
Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
`console.*`; never log raw credentials while debugging.
|
||||
|
||||
If the Nx daemon gets into a bad state before rerunning Electron:
|
||||
|
||||
@@ -238,6 +236,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code
|
||||
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
|
||||
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
|
||||
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
|
||||
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
|
||||
- **shared/m3u-utils** - M3U playlist utilities
|
||||
- **shared/testing** - Shared test helpers
|
||||
@@ -573,6 +572,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- `favorites` - User favorites
|
||||
- `recentlyViewed` - Watch history
|
||||
- `epgChannels`, `epgPrograms` - Persisted EPG data
|
||||
- `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)
|
||||
- `playbackPositions` - Resume positions
|
||||
- `downloads` - Download manager state
|
||||
- `appState` - Key-value app state (also tracks one-off data migrations)
|
||||
@@ -629,9 +629,137 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
`libs/ui/playback/src/lib/shaka-engine/`; details in
|
||||
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`.
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
|
||||
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
|
||||
engine` (restart required) or
|
||||
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV
|
||||
experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,
|
||||
EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the
|
||||
preload frame pump uploads them to
|
||||
`<canvas data-embedded-mpv-frame>`. Shared `app-player-controls` owns the DOM
|
||||
UI; native-view retains the legacy dock. On Linux, only
|
||||
`iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the
|
||||
addon, and frame reader must not. Pristine afterPack/unpacked layouts scan
|
||||
Electron libraries recursively; extracted Snap payloads exclude only the
|
||||
package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.
|
||||
Every other directory remains recursive, and Electron-library symlinks still
|
||||
fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;
|
||||
`afterPack` alone owns the profile-normalized unpacked native tree, and
|
||||
package checks reject every archived `/electron-backend/native/**` entry.
|
||||
Packaged addon, frame-reader, and helper discovery uses only package-owned
|
||||
`app.asar.unpacked` paths; cwd/dist candidates remain development-only.
|
||||
Official x64 packages use three separate profiles:
|
||||
DEB/RPM/Pacman depend on system libmpv plus the helper's direct
|
||||
EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and
|
||||
Flatpak bundles the same closure. Flatpak is an isolated packaging pass and
|
||||
keeps `iptvnator` as the real Electron ELF so Electron Builder's
|
||||
`electron-wrapper` passes it directly to Zypak. Other Linux targets retain the
|
||||
conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed
|
||||
Flatpak/non-Flatpak target sets fail before mutation. Exact system
|
||||
dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`,
|
||||
RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
|
||||
Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;
|
||||
Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.
|
||||
ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the
|
||||
fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;
|
||||
any failure keeps the sandbox enabled, records a stable reason, and falls
|
||||
back to native-view without crashing. Snap is `core22`/strict and uses an
|
||||
exact private `shared-memory` plug plus the `graphics-core22` content plug at
|
||||
a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the
|
||||
default provider. Its only provider-data layouts bind `/usr/share/libdrm`
|
||||
from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to
|
||||
`$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable
|
||||
status after disconnect, then reconnects and requires success. The helper
|
||||
links `libGL.so.1`, and probe/playback share a sanitized loader environment
|
||||
in which ambient audit, preload, library, graphics-driver, and shell-startup
|
||||
overrides are removed; the validated private closure plus trusted host GL,
|
||||
graphics-content, core22 base x64, and exact GNOME-platform roots have
|
||||
explicit precedence. The core22 base stays ahead of GNOME so the older
|
||||
`libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The
|
||||
extracted-artifact verifier removes the identical unsafe loader/graphics/
|
||||
shell set before direct helper smoke while preserving selectors such as
|
||||
`LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,
|
||||
removes exported `BASH_FUNC_*` functions, and
|
||||
launches probe/playback through the regular executable
|
||||
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
|
||||
disconnected provider returns `snap-graphics-provider-unavailable` before
|
||||
helper spawn. The packaging-only
|
||||
`--embedded-mpv-runtime-probe` app switch runs the complete packaged gate
|
||||
before BrowserWindow startup and emits one availability JSON line. A nonzero
|
||||
helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is
|
||||
present only for an exact protocol-v1 line carrying a fixed allowlisted
|
||||
reason, and its optional `helperDetail` must be 1–1024 printable ASCII
|
||||
characters. Invalid detail suppresses both helper fields. Every probe uses
|
||||
an explicit 16 MiB aggregate captured-output ceiling independent of tracing.
|
||||
With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately
|
||||
as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an
|
||||
explicit `truncated` field; trace-write failure cannot change availability.
|
||||
Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded
|
||||
channel. The exact packaged Flatpak `/app` context reconstructs only
|
||||
Freedesktop Platform 24.08's immutable
|
||||
`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that
|
||||
application-level probe instead of the helper directly. The packaged x64
|
||||
Playwright smoke runs its fixture-contract target first and passes Chromium
|
||||
`--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass
|
||||
the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux
|
||||
packages carry hash-validated
|
||||
`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.
|
||||
CI caches the staged runtime plus immutable source inputs, never finished
|
||||
notices or the compliance tarball; it regenerates those notices and the
|
||||
VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current
|
||||
checkout while preserving the exact pinned six recursive libplacebo
|
||||
submodule records. Each record is canonical `full-commit safe/path`;
|
||||
clone-depth dependent `git describe` annotations are discarded and never
|
||||
form part of the provenance identity. Its source index carries the globally sorted libplacebo
|
||||
directory/file/symlink inventory; file hashes, sizes, executable bits, link
|
||||
targets, aggregates, and canonical tree digest must match the trusted pinned
|
||||
checkout. The archive has an exact member/type layout and its
|
||||
`metadata/archive-sha256.txt` records must match the actual source archives.
|
||||
Concatenated tar/xz streams are inspected past every end marker. Every
|
||||
bundled x64 package manifest binds the final archive's SHA-256 and repository
|
||||
revision; system and marker-only packages do not carry that binding. Snap
|
||||
Store
|
||||
publication runs only from a public `v*` GitHub release that already
|
||||
contains the Snap assets and exactly one source archive. Before any upload,
|
||||
the workflow hashes and checks the archive's exact member/type set and size
|
||||
bounds, verifies its clean tag revision, pinned sources including the six
|
||||
recursive submodule records and exact libplacebo tree digest, legal payload,
|
||||
and exact released tooling, then performs bounded extraction and static
|
||||
validation for every Snap. That public-release boundary independently
|
||||
revalidates the exact strict `meta/snap.yaml` graphics/shared-memory
|
||||
contract and enumerates `resources/app.asar`, rejecting any archived
|
||||
`electron-backend/native/**` payload before publication. Its bounded ASAR
|
||||
header reader uses only Node built-ins and released local tooling, so the
|
||||
clean tag checkout does not require `node_modules`. Exactly one x64 Snap
|
||||
must have matching
|
||||
`sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.
|
||||
Checkout and artifact-transfer actions are pinned to full commits; checkout
|
||||
does not persist credentials, and repository credentials are scoped to
|
||||
download steps. A secretless verification job copies assets through
|
||||
no-follow descriptors, checks them before and after inspection, writes an
|
||||
exact receipt, fully reverifies a root-owned read-only snapshot, and
|
||||
transfers only that data through the pinned artifact service while passing
|
||||
the receipt digest separately through a job output. The dependent publish
|
||||
job uses a bounded `ubuntu-latest` runner with no checkout or release-tag
|
||||
code, verifies that digest plus the exact receipt, asset hashes, and
|
||||
file-only layout, root-seals the data again, and installs Snapcraft directly.
|
||||
Its final fixed shell step alone receives the Store credential, resolves no
|
||||
PATH command, executes no released code, and exposes that credential only to
|
||||
each exact
|
||||
`/snap/bin/snapcraft upload --release=edge` process. Candidate/stable
|
||||
promotion is manual after installed-Snap frame-copy and missing-runtime
|
||||
fallback smoke; GitHub Actions never promotes automatically. On Windows,
|
||||
package validation requires the exact MPV DLL named by the helper's PE import
|
||||
table beside the executable.
|
||||
Backend adapter:
|
||||
`apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;
|
||||
shared-controls adapter:
|
||||
`libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;
|
||||
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
||||
contracts: `docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`.
|
||||
- Shared web picture-in-picture stays inside that default-off rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
@@ -658,8 +786,10 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
|
||||
- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
|
||||
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
|
||||
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
|
||||
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
|
||||
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
|
||||
@@ -680,19 +810,20 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- XMLTV format support
|
||||
- Background parsing in worker thread
|
||||
- Stored in database for quick lookup
|
||||
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
|
||||
|
||||
**TMDB Metadata Enrichment** (opt-in):
|
||||
|
||||
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
|
||||
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`)
|
||||
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
|
||||
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
|
||||
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-merge.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
|
||||
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
|
||||
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
|
||||
- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
|
||||
- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)
|
||||
- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting
|
||||
- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker.
|
||||
- Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`)
|
||||
- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About
|
||||
- See `docs/architecture/tmdb-metadata-enrichment.md`
|
||||
@@ -751,6 +882,9 @@ Build configurations in `apps/web/project.json`:
|
||||
**Factory Pattern Implementation**:
|
||||
The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.
|
||||
|
||||
**Build Commit In About**:
|
||||
CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `"<version> (<short-sha>)"`. The semver version itself deliberately stays untouched — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
|
||||
|
||||
@@ -300,13 +300,6 @@ This redirects the SQLite database, Electron user data, and local config under
|
||||
the given directory. Delete that directory whenever you want a fresh empty
|
||||
state.
|
||||
|
||||
If you need to debug renderer freezes or GPU/compositor issues in Electron, you
|
||||
can disable hardware acceleration for a run:
|
||||
|
||||
```
|
||||
$ IPTVNATOR_DISABLE_HARDWARE_ACCELERATION=1 pnpm run serve:backend
|
||||
```
|
||||
|
||||
If you need startup diagnostics for a white screen or a frozen route, you can
|
||||
also turn on opt-in Electron tracing. These logs are written to the Electron
|
||||
terminal output so they still help when the renderer DevTools never open:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import baseConfig from './playwright.config';
|
||||
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
outputDir:
|
||||
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke',
|
||||
reporter: [
|
||||
['list'],
|
||||
[
|
||||
'html',
|
||||
{
|
||||
outputFolder:
|
||||
'../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke',
|
||||
},
|
||||
],
|
||||
[
|
||||
'json',
|
||||
{
|
||||
outputFile:
|
||||
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json',
|
||||
},
|
||||
],
|
||||
],
|
||||
timeout: 120000,
|
||||
webServer: [],
|
||||
});
|
||||
@@ -12,6 +12,26 @@
|
||||
"e2e": {
|
||||
"dependsOn": ["electron-backend:build-e2e"]
|
||||
},
|
||||
"packaged-frame-copy-smoke": {
|
||||
"dependsOn": ["test-packaged-frame-copy-fixtures"],
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"outputs": [
|
||||
"{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke",
|
||||
"{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/electron-backend-e2e",
|
||||
"command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts"
|
||||
}
|
||||
},
|
||||
"test-packaged-frame-copy-fixtures": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/electron-backend-e2e",
|
||||
"command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdirSync, readdirSync } from 'fs';
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { createServer } from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { join } from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
@@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise<void> {
|
||||
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
||||
}
|
||||
|
||||
interface RangeServerRequest {
|
||||
ifRange?: string;
|
||||
range?: string;
|
||||
}
|
||||
|
||||
interface ThrottledRangeServer {
|
||||
close: () => Promise<void>;
|
||||
payload: Buffer;
|
||||
requests: RangeServerRequest[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
|
||||
|
||||
/**
|
||||
* Serves a payload slowly on the first (full) request so the UI has a wide
|
||||
* window to pause mid-transfer, and answers Range requests with an immediate
|
||||
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
|
||||
*/
|
||||
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
|
||||
const payload = Buffer.from(
|
||||
Array.from({ length: 96 * 1024 }, (_, index) =>
|
||||
String(index % 10)
|
||||
).join('')
|
||||
);
|
||||
const requests: RangeServerRequest[] = [];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const range = req.headers.range;
|
||||
const ifRange = req.headers['if-range'];
|
||||
requests.push({
|
||||
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
|
||||
range: typeof range === 'string' ? range : undefined,
|
||||
});
|
||||
|
||||
const offset = range
|
||||
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
|
||||
: 0;
|
||||
if (range && Number.isFinite(offset)) {
|
||||
res.writeHead(206, {
|
||||
'Content-Length': payload.length - offset,
|
||||
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: RANGE_SERVER_ETAG,
|
||||
});
|
||||
res.end(payload.subarray(offset));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Length': payload.length,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: RANGE_SERVER_ETAG,
|
||||
});
|
||||
// First 16 KiB immediately, then a trickle: the transfer stays alive
|
||||
// for tens of seconds unless it is paused or resumed via Range.
|
||||
let sent = 16 * 1024;
|
||||
res.write(payload.subarray(0, sent));
|
||||
const timer = setInterval(() => {
|
||||
if (sent >= payload.length) {
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.write(payload.subarray(sent, sent + 2 * 1024));
|
||||
sent += 2 * 1024;
|
||||
}, 150);
|
||||
res.on('close', () => clearInterval(timer));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
payload,
|
||||
requests,
|
||||
url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* On a cold profile the renderer can query SQLite while the DB worker is
|
||||
* still creating tables ("database is locked" / "no such table" on slow CI
|
||||
@@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => {
|
||||
await fileServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const rangeServer = await createThrottledRangeServer();
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Pause Portal',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openDownloadsPage(app.mainWindow);
|
||||
|
||||
const downloadsDir = join(dataDir, 'e2e-pause-downloads');
|
||||
mkdirSync(downloadsDir, { recursive: true });
|
||||
await app.electronApp.evaluate(({ dialog }, folder) => {
|
||||
dialog.showOpenDialog = async () =>
|
||||
({
|
||||
canceled: false,
|
||||
filePaths: [folder],
|
||||
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
|
||||
}, downloadsDir);
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Change Folder' })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.locator('.downloads__folder-inline-path')
|
||||
).toContainText('e2e-pause-downloads');
|
||||
|
||||
const startResult = await app.mainWindow.evaluate(
|
||||
async ({ url, folder }) =>
|
||||
window.electron?.downloadsStart?.({
|
||||
playlistId: 'e2e-playlist',
|
||||
xtreamId: 7373,
|
||||
contentType: 'vod',
|
||||
title: 'E2E Pause Movie',
|
||||
url,
|
||||
downloadFolder: folder,
|
||||
}),
|
||||
{ url: rangeServer.url, folder: downloadsDir }
|
||||
);
|
||||
expect(startResult?.error ?? null).toBeNull();
|
||||
|
||||
const item = app.mainWindow.locator('.downloads__item');
|
||||
await expect(item).toHaveCount(1, { timeout: 20000 });
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Downloading',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
|
||||
// Pause mid-transfer: the row flips to Paused and only a .part
|
||||
// file exists on disk (final file absent, progress retained).
|
||||
await item
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'pause' })
|
||||
.click();
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Paused',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
const pausedFiles = readdirSync(downloadsDir);
|
||||
expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']);
|
||||
const pausedBytes = statSync(
|
||||
join(downloadsDir, 'E2E Pause Movie.mp4.part')
|
||||
).size;
|
||||
expect(pausedBytes).toBeGreaterThan(0);
|
||||
expect(pausedBytes).toBeLessThan(rangeServer.payload.length);
|
||||
|
||||
// Resume: the runtime must continue via Range/If-Range instead of
|
||||
// restarting, and the assembled file must match the payload.
|
||||
await item
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'play_arrow' })
|
||||
.click();
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Completed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
const resumeRequest = rangeServer.requests.find(
|
||||
(entry) => entry.range
|
||||
);
|
||||
expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/);
|
||||
expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG);
|
||||
|
||||
expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']);
|
||||
const finalFile = readFileSync(
|
||||
join(downloadsDir, 'E2E Pause Movie.mp4')
|
||||
);
|
||||
expect(finalFile.equals(rangeServer.payload)).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rangeServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
} from '@playwright/test';
|
||||
import { createServer, Server } from 'http';
|
||||
import {
|
||||
accessSync,
|
||||
constants as fsConstants,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
|
||||
export const workspaceRoot = resolve(__dirname, '../../..');
|
||||
export const electronMainPath = join(
|
||||
@@ -70,7 +74,7 @@ type ElectronFixtures = {
|
||||
dataDir: string;
|
||||
};
|
||||
|
||||
type LaunchElectronAppOptions = {
|
||||
export type LaunchElectronAppOptions = {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
@@ -171,7 +175,142 @@ export async function launchElectronApp(
|
||||
};
|
||||
}
|
||||
|
||||
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
|
||||
/**
|
||||
* Resolve the x64 unpacked Linux executable produced by electron-builder.
|
||||
* An explicit path wins so CI can point at an AppImage/Flatpak extraction
|
||||
* without relying on electron-builder's local output directory names.
|
||||
*/
|
||||
export function resolvePackagedLinuxExecutable(
|
||||
explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE']
|
||||
): string | undefined {
|
||||
if (explicitPath?.trim()) {
|
||||
return resolve(explicitPath.trim());
|
||||
}
|
||||
|
||||
const executablesRoot = join(workspaceRoot, 'dist', 'executables');
|
||||
if (!existsSync(executablesRoot)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const unpackedDirectories = readdirSync(executablesRoot, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith('linux') &&
|
||||
entry.name.endsWith('-unpacked') &&
|
||||
!entry.name.includes('arm')
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const leftPriority = left.name === 'linux-unpacked' ? 0 : 1;
|
||||
const rightPriority = right.name === 'linux-unpacked' ? 0 : 1;
|
||||
return (
|
||||
leftPriority - rightPriority ||
|
||||
left.name.localeCompare(right.name)
|
||||
);
|
||||
});
|
||||
|
||||
for (const directory of unpackedDirectories) {
|
||||
for (const executableName of ['IPTVnator', 'iptvnator']) {
|
||||
const candidate = join(
|
||||
executablesRoot,
|
||||
directory.name,
|
||||
executableName
|
||||
);
|
||||
try {
|
||||
accessSync(candidate, fsConstants.X_OK);
|
||||
if (statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Keep looking for the next unpacked x64 layout.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getPackagedLinuxNativeDir(executablePath: string): string {
|
||||
return join(
|
||||
dirname(resolve(executablePath)),
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePackagedElectronLaunchArgs(
|
||||
getuid: (() => number) | undefined
|
||||
): string[] {
|
||||
const args = ['--ignore-gpu-blocklist'];
|
||||
if (typeof getuid === 'function' && getuid() === 0) {
|
||||
args.push('--no-sandbox');
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a real packaged Linux executable. Unlike the regular source E2E
|
||||
* launcher, this deliberately keeps Chromium's GPU path enabled and ignores
|
||||
* its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and
|
||||
* CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches
|
||||
* the renderer canvas.
|
||||
*/
|
||||
export async function launchPackagedElectronApp(
|
||||
executablePath: string,
|
||||
dataDir: string,
|
||||
options: LaunchElectronAppOptions = {}
|
||||
): Promise<LaunchedElectronApp> {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error(
|
||||
'The packaged embedded-MPV launcher is available on Linux only.'
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedExecutablePath = resolve(executablePath);
|
||||
try {
|
||||
accessSync(resolvedExecutablePath, fsConstants.X_OK);
|
||||
if (!statSync(resolvedExecutablePath).isFile()) {
|
||||
throw new Error('not a regular file');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const electronApp = await electron.launch({
|
||||
executablePath: resolvedExecutablePath,
|
||||
args: resolvePackagedElectronLaunchArgs(process.getuid),
|
||||
env: {
|
||||
...process.env,
|
||||
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS:
|
||||
process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1',
|
||||
...options.env,
|
||||
ELECTRON_IS_DEV: '0',
|
||||
IPTVNATOR_E2E_DATA_DIR: dataDir,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
});
|
||||
attachElectronProcessDiagnostics(electronApp);
|
||||
|
||||
const mainWindow = await findMainWindow(electronApp);
|
||||
await waitForAppReady(mainWindow);
|
||||
|
||||
return {
|
||||
electronApp,
|
||||
mainWindow,
|
||||
};
|
||||
}
|
||||
|
||||
function attachElectronProcessDiagnostics(
|
||||
electronApp: ElectronApplication
|
||||
): void {
|
||||
if (!process.env['CI']) {
|
||||
return;
|
||||
}
|
||||
@@ -235,10 +374,7 @@ async function waitForPromiseWithTimeout(
|
||||
return await Promise.race([
|
||||
promise.then(() => true),
|
||||
new Promise<boolean>((resolvePromise) => {
|
||||
timeoutId = setTimeout(
|
||||
() => resolvePromise(false),
|
||||
timeoutMs
|
||||
);
|
||||
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
@@ -297,11 +433,11 @@ async function waitForAppReady(page: Page): Promise<void> {
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
appRootLength:
|
||||
document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
|
||||
document.querySelector('app-root')?.innerHTML.trim().length ??
|
||||
0,
|
||||
baseHref:
|
||||
document
|
||||
.querySelector('base')
|
||||
?.getAttribute('href') ?? '<missing>',
|
||||
document.querySelector('base')?.getAttribute('href') ??
|
||||
'<missing>',
|
||||
readyState: document.readyState,
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
@@ -357,7 +493,7 @@ export async function importM3uPlaylistFromNativeDialog(
|
||||
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
|
||||
|
||||
await fileInput.evaluate((element, selectedFilePath) => {
|
||||
(element as HTMLInputElement).dataset.filePathOverride =
|
||||
(element as HTMLInputElement).dataset['filePathOverride'] =
|
||||
selectedFilePath;
|
||||
}, filePath);
|
||||
await fileInput.setInputFiles(filePath);
|
||||
@@ -501,15 +637,17 @@ async function clickDialogMethodOption(
|
||||
label: RegExp,
|
||||
legacySelector?: string
|
||||
): Promise<void> {
|
||||
const optionByRadio = dialog
|
||||
.getByRole('radio', { name: label })
|
||||
.first();
|
||||
const optionByRadio = dialog.getByRole('radio', { name: label }).first();
|
||||
if ((await optionByRadio.count()) > 0) {
|
||||
await optionByRadio.click();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
|
||||
for (const tablistLabel of [
|
||||
'Source method',
|
||||
'Playlist category',
|
||||
'M3U source',
|
||||
]) {
|
||||
const tablist = dialog
|
||||
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
|
||||
.first();
|
||||
@@ -541,9 +679,7 @@ async function clickDialogMethodOption(
|
||||
}
|
||||
|
||||
if (!legacySelector) {
|
||||
throw new Error(
|
||||
`Could not find dialog option matching ${label}.`
|
||||
);
|
||||
throw new Error(`Could not find dialog option matching ${label}.`);
|
||||
}
|
||||
|
||||
await dialog.locator(legacySelector).click();
|
||||
@@ -704,9 +840,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
|
||||
const attributes = [
|
||||
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
|
||||
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
|
||||
channel.tvgLanguage
|
||||
? `tvg-language="${channel.tvgLanguage}"`
|
||||
: '',
|
||||
channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '',
|
||||
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
|
||||
channel.logo ? `tvg-logo="${channel.logo}"` : '',
|
||||
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
|
||||
@@ -889,9 +1023,7 @@ export async function switchUnifiedCollectionContent(
|
||||
await clickButtonToggleOption(toggleGroup, contentLabel);
|
||||
}
|
||||
|
||||
export async function clearCurrentUnifiedCollection(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: /Clear .* (favorites|recently viewed)/i,
|
||||
@@ -1464,6 +1596,15 @@ export async function expectWorkspaceSearchStatus(
|
||||
).toHaveText(expected);
|
||||
}
|
||||
|
||||
/** The degraded-search hint chip must be absent (e.g. complete local search). */
|
||||
export async function expectNoWorkspaceSearchStatus(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .search-chip--status')
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function startPortalDebugCapture(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const electronApi = window.electron as typeof window.electron & {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert = require('node:assert/strict');
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import {
|
||||
createDisposablePackagedLinuxApp,
|
||||
createPackagedEntryGuard,
|
||||
readPackagedRuntimeIdentity,
|
||||
} from './embedded-mpv-frame-copy-packaged-filesystem';
|
||||
|
||||
const temporaryDirectories = new Set<string>();
|
||||
|
||||
type PackageFixture = {
|
||||
executablePath: string;
|
||||
libmpvAliasPath: string;
|
||||
libmpvSonamePath: string;
|
||||
nativeDir: string;
|
||||
packageRoot: string;
|
||||
payloadPath: string;
|
||||
runtimeManifestPath: string;
|
||||
};
|
||||
|
||||
function createPackageFixture(): PackageFixture {
|
||||
const packageRoot = mkdtempSync(
|
||||
join(tmpdir(), 'iptvnator-packaged-fixture-source-')
|
||||
);
|
||||
temporaryDirectories.add(packageRoot);
|
||||
const executablePath = join(packageRoot, 'IPTVnator');
|
||||
const nativeDir = join(
|
||||
packageRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const payloadPath = join(packageRoot, 'resources', 'payload.bin');
|
||||
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
|
||||
const runtimeLibraryDir = join(nativeDir, 'lib');
|
||||
const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2');
|
||||
const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so');
|
||||
|
||||
mkdirSync(runtimeLibraryDir, { recursive: true });
|
||||
writeFileSync(executablePath, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(executablePath, 0o755);
|
||||
writeFileSync(payloadPath, 'packaged payload');
|
||||
chmodSync(payloadPath, 0o640);
|
||||
writeFileSync(libmpvSonamePath, 'packaged libmpv');
|
||||
symlinkSync('libmpv.so.2', libmpvAliasPath);
|
||||
writeFileSync(
|
||||
runtimeManifestPath,
|
||||
JSON.stringify({
|
||||
arch: 'x64',
|
||||
libmpvSoname: 'libmpv.so.2',
|
||||
platform: 'linux',
|
||||
profile: 'portable',
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
symlinkSync(
|
||||
'payload.bin',
|
||||
join(packageRoot, 'resources', 'payload-link')
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath,
|
||||
libmpvAliasPath,
|
||||
libmpvSonamePath,
|
||||
nativeDir,
|
||||
packageRoot,
|
||||
payloadPath,
|
||||
runtimeManifestPath,
|
||||
};
|
||||
}
|
||||
|
||||
function entryExists(entryPath: string): boolean {
|
||||
try {
|
||||
lstatSync(entryPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories) {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
temporaryDirectories.clear();
|
||||
});
|
||||
|
||||
describe('disposable unpacked package clone', () => {
|
||||
it('requires a safe versioned libmpv target in the packaged manifest', () => {
|
||||
const source = createPackageFixture();
|
||||
|
||||
assert.equal(
|
||||
readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname,
|
||||
'libmpv.so.2'
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
source.runtimeManifestPath,
|
||||
JSON.stringify({
|
||||
arch: 'x64',
|
||||
libmpvSoname: '../libmpv.so.2',
|
||||
platform: 'linux',
|
||||
profile: 'portable',
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
assert.throws(
|
||||
() => readPackagedRuntimeIdentity(source.nativeDir),
|
||||
/libmpvSoname/
|
||||
);
|
||||
});
|
||||
|
||||
it('hides and restores a cloned regular dependency without changing the source package', () => {
|
||||
const source = createPackageFixture();
|
||||
const clone = createDisposablePackagedLinuxApp(source.executablePath);
|
||||
temporaryDirectories.add(clone.temporaryRoot);
|
||||
|
||||
const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2');
|
||||
const guard = createPackagedEntryGuard(clonedLibmpvPath, {
|
||||
expectedKind: 'regular-file',
|
||||
hiddenDirectory: clone.temporaryRoot,
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
|
||||
assert.equal(
|
||||
statSync(clonedLibmpvPath).ino,
|
||||
statSync(source.libmpvSonamePath).ino
|
||||
);
|
||||
|
||||
guard.hide();
|
||||
|
||||
assert.equal(entryExists(clonedLibmpvPath), false);
|
||||
assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true);
|
||||
assert.equal(
|
||||
readFileSync(source.libmpvSonamePath, 'utf8'),
|
||||
'packaged libmpv'
|
||||
);
|
||||
|
||||
guard.restore();
|
||||
|
||||
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
|
||||
assert.equal(
|
||||
statSync(clonedLibmpvPath).ino,
|
||||
statSync(source.libmpvSonamePath).ino
|
||||
);
|
||||
} finally {
|
||||
guard.restore();
|
||||
clone.cleanup();
|
||||
temporaryDirectories.delete(clone.temporaryRoot);
|
||||
}
|
||||
|
||||
assert.equal(entryExists(source.libmpvSonamePath), true);
|
||||
});
|
||||
|
||||
it('hides and restores a cloned symbolic link without dereferencing it', () => {
|
||||
const source = createPackageFixture();
|
||||
const clone = createDisposablePackagedLinuxApp(source.executablePath);
|
||||
temporaryDirectories.add(clone.temporaryRoot);
|
||||
|
||||
const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so');
|
||||
const guard = createPackagedEntryGuard(clonedAliasPath, {
|
||||
expectedKind: 'symbolic-link',
|
||||
hiddenDirectory: clone.temporaryRoot,
|
||||
});
|
||||
|
||||
try {
|
||||
guard.hide();
|
||||
assert.equal(entryExists(clonedAliasPath), false);
|
||||
assert.equal(
|
||||
lstatSync(source.libmpvAliasPath).isSymbolicLink(),
|
||||
true
|
||||
);
|
||||
assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2');
|
||||
|
||||
guard.restore();
|
||||
assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true);
|
||||
assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2');
|
||||
} finally {
|
||||
guard.restore();
|
||||
clone.cleanup();
|
||||
temporaryDirectories.delete(clone.temporaryRoot);
|
||||
}
|
||||
});
|
||||
|
||||
it('hardlinks regular files and preserves modes, symlinks, and the source manifest', () => {
|
||||
const source = createPackageFixture();
|
||||
const clone = createDisposablePackagedLinuxApp(source.executablePath);
|
||||
temporaryDirectories.add(clone.temporaryRoot);
|
||||
|
||||
try {
|
||||
assert.notEqual(clone.packageRoot, source.packageRoot);
|
||||
assert.equal(
|
||||
statSync(clone.executablePath).mode & 0o777,
|
||||
statSync(source.executablePath).mode & 0o777
|
||||
);
|
||||
|
||||
const clonedPayloadPath = join(
|
||||
clone.packageRoot,
|
||||
'resources',
|
||||
'payload.bin'
|
||||
);
|
||||
assert.equal(
|
||||
statSync(clonedPayloadPath).ino,
|
||||
statSync(source.payloadPath).ino
|
||||
);
|
||||
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const clonedLinkPath = join(
|
||||
clone.packageRoot,
|
||||
'resources',
|
||||
'payload-link'
|
||||
);
|
||||
assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true);
|
||||
assert.equal(readlinkSync(clonedLinkPath), 'payload.bin');
|
||||
}
|
||||
|
||||
const clonedRuntimeManifestPath = join(
|
||||
clone.nativeDir,
|
||||
'embedded-mpv-runtime.json'
|
||||
);
|
||||
assert.equal(existsSync(clonedRuntimeManifestPath), true);
|
||||
assert.equal(existsSync(source.runtimeManifestPath), true);
|
||||
} finally {
|
||||
clone.cleanup();
|
||||
temporaryDirectories.delete(clone.temporaryRoot);
|
||||
}
|
||||
|
||||
assert.equal(existsSync(clone.temporaryRoot), false);
|
||||
assert.equal(existsSync(source.runtimeManifestPath), true);
|
||||
});
|
||||
|
||||
it('copies a regular file when hardlinking is unavailable', () => {
|
||||
const source = createPackageFixture();
|
||||
const clone = createDisposablePackagedLinuxApp(source.executablePath, {
|
||||
linkFile() {
|
||||
throw Object.assign(new Error('cross-device hardlink'), {
|
||||
code: 'EXDEV',
|
||||
});
|
||||
},
|
||||
});
|
||||
temporaryDirectories.add(clone.temporaryRoot);
|
||||
|
||||
try {
|
||||
assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755);
|
||||
const clonedPayloadPath = join(
|
||||
clone.packageRoot,
|
||||
'resources',
|
||||
'payload.bin'
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(clonedPayloadPath, 'utf8'),
|
||||
readFileSync(source.payloadPath, 'utf8')
|
||||
);
|
||||
assert.notEqual(
|
||||
statSync(clonedPayloadPath).ino,
|
||||
statSync(source.payloadPath).ino
|
||||
);
|
||||
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
|
||||
} finally {
|
||||
clone.cleanup();
|
||||
temporaryDirectories.delete(clone.temporaryRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
type Stats,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, dirname, join, resolve } from 'path';
|
||||
import { getPackagedLinuxNativeDir } from './electron-test-fixtures';
|
||||
|
||||
export type DisposablePackagedLinuxApp = {
|
||||
cleanup: () => void;
|
||||
executablePath: string;
|
||||
nativeDir: string;
|
||||
packageRoot: string;
|
||||
temporaryRoot: string;
|
||||
};
|
||||
|
||||
export type DisposablePackagedLinuxAppOptions = {
|
||||
linkFile?: (existingPath: string, newPath: string) => void;
|
||||
};
|
||||
|
||||
export type PackagedRuntimeIdentity = {
|
||||
arch: string;
|
||||
libmpvSoname: string;
|
||||
platform: string;
|
||||
profile: string;
|
||||
runtimeMode: string;
|
||||
};
|
||||
|
||||
export type PackagedEntryKind = 'regular-file' | 'symbolic-link';
|
||||
|
||||
export type PackagedEntryGuard = {
|
||||
hide: () => void;
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
export type PackagedEntryGuardOptions = {
|
||||
expectedKind: PackagedEntryKind;
|
||||
hiddenDirectory: string;
|
||||
};
|
||||
|
||||
const HARDLINK_COPY_FALLBACK_CODES = new Set([
|
||||
'EACCES',
|
||||
'EMLINK',
|
||||
'ENOSYS',
|
||||
'ENOTSUP',
|
||||
'EPERM',
|
||||
'EXDEV',
|
||||
]);
|
||||
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
|
||||
|
||||
function entryKindMatches(
|
||||
stats: Stats,
|
||||
expectedKind: PackagedEntryKind
|
||||
): boolean {
|
||||
return expectedKind === 'regular-file'
|
||||
? stats.isFile() && !stats.isSymbolicLink()
|
||||
: stats.isSymbolicLink();
|
||||
}
|
||||
|
||||
function entryExistsByLstat(entryPath: string): boolean {
|
||||
try {
|
||||
lstatSync(entryPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function clonePackagedEntry(
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
linkFile: (existingPath: string, newPath: string) => void
|
||||
): void {
|
||||
const sourceStats = lstatSync(sourcePath);
|
||||
|
||||
if (sourceStats.isDirectory()) {
|
||||
mkdirSync(destinationPath);
|
||||
for (const entry of readdirSync(sourcePath)) {
|
||||
clonePackagedEntry(
|
||||
join(sourcePath, entry),
|
||||
join(destinationPath, entry),
|
||||
linkFile
|
||||
);
|
||||
}
|
||||
chmodSync(destinationPath, sourceStats.mode & 0o7777);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceStats.isSymbolicLink()) {
|
||||
symlinkSync(readlinkSync(sourcePath), destinationPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceStats.isFile()) {
|
||||
throw new Error(
|
||||
`Unsupported packaged runtime entry type at ${sourcePath}.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
linkFile(sourcePath, destinationPath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) {
|
||||
throw error;
|
||||
}
|
||||
copyFileSync(sourcePath, destinationPath);
|
||||
chmodSync(destinationPath, sourceStats.mode & 0o7777);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDisposablePackagedLinuxApp(
|
||||
executablePath: string,
|
||||
options: DisposablePackagedLinuxAppOptions = {}
|
||||
): DisposablePackagedLinuxApp {
|
||||
const sourceExecutablePath = resolve(executablePath);
|
||||
const sourcePackageRoot = dirname(sourceExecutablePath);
|
||||
const temporaryRoot = mkdtempSync(
|
||||
join(tmpdir(), 'iptvnator-packaged-frame-copy-')
|
||||
);
|
||||
const packageRoot = join(temporaryRoot, basename(sourcePackageRoot));
|
||||
let cleaned = false;
|
||||
|
||||
try {
|
||||
clonePackagedEntry(
|
||||
sourcePackageRoot,
|
||||
packageRoot,
|
||||
options.linkFile ?? linkSync
|
||||
);
|
||||
} catch (error) {
|
||||
rmSync(temporaryRoot, { force: true, recursive: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const clonedExecutablePath = join(
|
||||
packageRoot,
|
||||
basename(sourceExecutablePath)
|
||||
);
|
||||
return {
|
||||
cleanup() {
|
||||
if (cleaned) {
|
||||
return;
|
||||
}
|
||||
rmSync(temporaryRoot, { force: true, recursive: true });
|
||||
cleaned = true;
|
||||
},
|
||||
executablePath: clonedExecutablePath,
|
||||
nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath),
|
||||
packageRoot,
|
||||
temporaryRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPackagedEntryGuard(
|
||||
entryPath: string,
|
||||
options: PackagedEntryGuardOptions
|
||||
): PackagedEntryGuard {
|
||||
const guardedEntryPath = resolve(entryPath);
|
||||
const hiddenDirectory = resolve(options.hiddenDirectory);
|
||||
const hiddenEntryPath = join(
|
||||
hiddenDirectory,
|
||||
`.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}`
|
||||
);
|
||||
let hidden = false;
|
||||
|
||||
return {
|
||||
hide() {
|
||||
const entryStats = lstatSync(guardedEntryPath);
|
||||
if (!entryKindMatches(entryStats, options.expectedKind)) {
|
||||
throw new Error(
|
||||
`Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}`
|
||||
);
|
||||
}
|
||||
const hiddenDirectoryStats = lstatSync(hiddenDirectory);
|
||||
if (
|
||||
hiddenDirectoryStats.isSymbolicLink() ||
|
||||
!hiddenDirectoryStats.isDirectory()
|
||||
) {
|
||||
throw new Error(
|
||||
`Packaged entry stash is not a regular directory: ${hiddenDirectory}`
|
||||
);
|
||||
}
|
||||
if (entryExistsByLstat(hiddenEntryPath)) {
|
||||
throw new Error(
|
||||
`Stale hidden packaged entry exists: ${hiddenEntryPath}`
|
||||
);
|
||||
}
|
||||
renameSync(guardedEntryPath, hiddenEntryPath);
|
||||
hidden = true;
|
||||
},
|
||||
restore() {
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
if (!entryExistsByLstat(hiddenEntryPath)) {
|
||||
throw new Error(
|
||||
`Hidden packaged entry disappeared before restore: ${hiddenEntryPath}`
|
||||
);
|
||||
}
|
||||
if (entryExistsByLstat(guardedEntryPath)) {
|
||||
throw new Error(
|
||||
`Refusing to overwrite packaged entry during restore: ${guardedEntryPath}`
|
||||
);
|
||||
}
|
||||
renameSync(hiddenEntryPath, guardedEntryPath);
|
||||
hidden = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readPackagedRuntimeIdentity(
|
||||
nativeDir: string
|
||||
): PackagedRuntimeIdentity {
|
||||
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(runtimeManifestPath, 'utf8')
|
||||
) as Partial<PackagedRuntimeIdentity>;
|
||||
|
||||
for (const field of [
|
||||
'arch',
|
||||
'platform',
|
||||
'profile',
|
||||
'runtimeMode',
|
||||
] as const) {
|
||||
if (typeof parsed[field] !== 'string' || !parsed[field]) {
|
||||
throw new Error(
|
||||
`Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof parsed.libmpvSoname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname)
|
||||
) {
|
||||
throw new Error(
|
||||
`Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.`
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as PackagedRuntimeIdentity;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert = require('node:assert/strict');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
import './embedded-mpv-frame-copy-packaged-filesystem.tests';
|
||||
import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures';
|
||||
import packagedPlaywrightConfig from '../playwright.packaged.config';
|
||||
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
|
||||
describe('packaged Electron launch arguments', () => {
|
||||
it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => {
|
||||
assert.deepEqual(
|
||||
resolvePackagedElectronLaunchArgs(() => 1000),
|
||||
['--ignore-gpu-blocklist']
|
||||
);
|
||||
assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [
|
||||
'--ignore-gpu-blocklist',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
resolvePackagedElectronLaunchArgs(() => 0),
|
||||
['--ignore-gpu-blocklist', '--no-sandbox']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native-view playback proof', () => {
|
||||
it('requires the loaded URL, a playing or paused state, and positive duration', () => {
|
||||
const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m';
|
||||
const baseSnapshot = {
|
||||
durationSeconds: 2,
|
||||
status: 'playing',
|
||||
streamUrl: expectedUrl,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulNativePlaybackSnapshot(
|
||||
{ ...baseSnapshot, status: 'paused' },
|
||||
`${expectedUrl}#ignored`
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulNativePlaybackSnapshot(
|
||||
{ ...baseSnapshot, durationSeconds: null },
|
||||
expectedUrl
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulNativePlaybackSnapshot(
|
||||
{ ...baseSnapshot, status: 'idle' },
|
||||
expectedUrl
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulNativePlaybackSnapshot(
|
||||
{ ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` },
|
||||
expectedUrl
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('loads the fixture and observes playback before disposing the native session', () => {
|
||||
const source = readFileSync(
|
||||
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const fallbackStart = source.indexOf('const launchedFallbackApp');
|
||||
const captureIndex = source.indexOf(
|
||||
'installEmbeddedMpvSessionCapture',
|
||||
fallbackStart
|
||||
);
|
||||
const loadIndex = source.indexOf(
|
||||
'loadEmbeddedMpvPlayback',
|
||||
fallbackStart
|
||||
);
|
||||
const proofIndex = source.indexOf(
|
||||
'isMeaningfulNativePlaybackSnapshot',
|
||||
fallbackStart
|
||||
);
|
||||
const exitCodeIndex = source.indexOf(
|
||||
'electronApp.process().exitCode',
|
||||
fallbackStart
|
||||
);
|
||||
const disposeIndex = source.indexOf(
|
||||
'disposeEmbeddedMpvSession',
|
||||
fallbackStart
|
||||
);
|
||||
|
||||
assert.ok(fallbackStart >= 0);
|
||||
assert.ok(captureIndex > fallbackStart);
|
||||
assert.ok(loadIndex > captureIndex);
|
||||
assert.ok(proofIndex > loadIndex);
|
||||
assert.ok(exitCodeIndex > proofIndex);
|
||||
assert.ok(disposeIndex > exitCodeIndex);
|
||||
});
|
||||
|
||||
it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => {
|
||||
const source = readFileSync(
|
||||
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const manifestIdentityIndex = source.indexOf(
|
||||
'const runtimeIdentity = readPackagedRuntimeIdentity'
|
||||
);
|
||||
const guardIndex = source.indexOf(
|
||||
'createPackagedEntryGuard(',
|
||||
manifestIdentityIndex
|
||||
);
|
||||
const sonameIndex = source.indexOf(
|
||||
'runtimeIdentity.libmpvSoname',
|
||||
guardIndex
|
||||
);
|
||||
const hideIndex = source.indexOf('.hide()', sonameIndex);
|
||||
const fallbackStart = source.indexOf(
|
||||
'const launchedFallbackApp',
|
||||
hideIndex
|
||||
);
|
||||
const missingReasonIndex = source.indexOf(
|
||||
"frameCopyUnavailableReason: 'runtime-library-missing'",
|
||||
fallbackStart
|
||||
);
|
||||
|
||||
assert.ok(manifestIdentityIndex >= 0);
|
||||
assert.ok(guardIndex > manifestIdentityIndex);
|
||||
assert.ok(sonameIndex > guardIndex);
|
||||
assert.ok(hideIndex > sonameIndex);
|
||||
assert.ok(fallbackStart > hideIndex);
|
||||
assert.ok(missingReasonIndex > fallbackStart);
|
||||
assert.doesNotMatch(source, /createRuntimeManifestGuard/);
|
||||
assert.doesNotMatch(source, /runtimeManifest\.hide/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedicated packaged smoke target', () => {
|
||||
it('inherits the GL mode from the workflow environment', () => {
|
||||
const source = readFileSync(
|
||||
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/);
|
||||
});
|
||||
|
||||
it('does not build the backend or start portal mock servers', () => {
|
||||
const project = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'project.json'), 'utf8')
|
||||
) as {
|
||||
targets?: Record<
|
||||
string,
|
||||
{
|
||||
cache?: boolean;
|
||||
dependsOn?: unknown;
|
||||
options?: { command?: string };
|
||||
}
|
||||
>;
|
||||
};
|
||||
const target = project.targets?.['packaged-frame-copy-smoke'];
|
||||
const packagedConfig = readFileSync(
|
||||
join(projectRoot, 'playwright.packaged.config.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
throw new Error('The packaged frame-copy smoke target is missing.');
|
||||
}
|
||||
assert.equal(target.cache, false);
|
||||
assert.deepEqual(target.dependsOn, [
|
||||
'test-packaged-frame-copy-fixtures',
|
||||
]);
|
||||
assert.match(
|
||||
target.options?.command ?? '',
|
||||
/playwright\.packaged\.config\.ts/
|
||||
);
|
||||
assert.match(
|
||||
target.options?.command ?? '',
|
||||
/embedded-mpv-frame-copy-packaged\.e2e\.ts/
|
||||
);
|
||||
assert.match(packagedConfig, /timeout:\s*120000/);
|
||||
assert.match(packagedConfig, /webServer:\s*\[\]/);
|
||||
assert.deepEqual(packagedPlaywrightConfig.webServer, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import type {
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createServer, type Server } from 'http';
|
||||
import sharp = require('sharp');
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import type {
|
||||
DisposablePackagedLinuxApp,
|
||||
PackagedEntryGuard,
|
||||
} from './embedded-mpv-frame-copy-packaged-filesystem';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__packagedEmbeddedMpvSessions?: EmbeddedMpvSession[];
|
||||
__packagedEmbeddedMpvUnsubscribe?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalMediaServer = {
|
||||
close: () => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function createTwoSecondY4mFixture(): Buffer {
|
||||
const width = 64;
|
||||
const height = 36;
|
||||
const framesPerSecond = 10;
|
||||
const frameCount = framesPerSecond * 2;
|
||||
const yPlaneBytes = width * height;
|
||||
const chromaPlaneBytes = (width / 2) * (height / 2);
|
||||
const chunks: Buffer[] = [
|
||||
Buffer.from(
|
||||
`YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`,
|
||||
'ascii'
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 0; index < frameCount; index += 1) {
|
||||
const evenFrame = index % 2 === 0;
|
||||
chunks.push(
|
||||
Buffer.from('FRAME\n', 'ascii'),
|
||||
Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150),
|
||||
Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44),
|
||||
Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21)
|
||||
);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
const onError = (error: Error) => {
|
||||
server.off('listening', onListening);
|
||||
rejectPromise(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off('error', onError);
|
||||
resolvePromise();
|
||||
};
|
||||
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(0, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
|
||||
const body = createTwoSecondY4mFixture();
|
||||
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = (request.url ?? '').split('?')[0];
|
||||
if (pathname !== resourcePath) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/);
|
||||
if (!range) {
|
||||
response.writeHead(200, {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': body.length,
|
||||
'Content-Type': 'video/x-yuv4mpeg',
|
||||
});
|
||||
response.end(request.method === 'HEAD' ? undefined : body);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Number(range[1]);
|
||||
const requestedEnd = range[2] ? Number(range[2]) : body.length - 1;
|
||||
const end = Math.min(requestedEnd, body.length - 1);
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
start < 0 ||
|
||||
start > end ||
|
||||
start >= body.length
|
||||
) {
|
||||
response.writeHead(416, {
|
||||
'Content-Range': `bytes */${body.length}`,
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(206, {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
||||
'Content-Type': 'video/x-yuv4mpeg',
|
||||
});
|
||||
response.end(
|
||||
request.method === 'HEAD'
|
||||
? undefined
|
||||
: body.subarray(start, end + 1)
|
||||
);
|
||||
});
|
||||
|
||||
await listen(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
await closeServer(server);
|
||||
throw new Error('Unable to resolve the local media server address.');
|
||||
}
|
||||
|
||||
return {
|
||||
close: () => closeServer(server),
|
||||
url: `http://127.0.0.1:${address.port}${resourcePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertNativeFallbackPrerequisites(): void {
|
||||
if (!process.env['DISPLAY']) {
|
||||
throw new Error(
|
||||
'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).'
|
||||
);
|
||||
}
|
||||
|
||||
const mpv = spawnSync('mpv', ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
if (mpv.status !== 0) {
|
||||
throw new Error(
|
||||
'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installEmbeddedMpvSessionCapture(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await app.mainWindow.evaluate(() => {
|
||||
window.__packagedEmbeddedMpvUnsubscribe?.();
|
||||
window.__packagedEmbeddedMpvSessions = [];
|
||||
window.__packagedEmbeddedMpvUnsubscribe =
|
||||
window.electron.onEmbeddedMpvSessionUpdate?.((session) => {
|
||||
window.__packagedEmbeddedMpvSessions?.push(session);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function installFrameCanvasAndSessionCapture(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await installEmbeddedMpvSessionCapture(app);
|
||||
await app.mainWindow.evaluate(() => {
|
||||
document.querySelector('canvas[data-embedded-mpv-frame]')?.remove();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.dataset['embeddedMpvFrame'] = '';
|
||||
canvas.dataset['testId'] = 'packaged-embedded-mpv-frame';
|
||||
Object.assign(canvas.style, {
|
||||
background: '#000',
|
||||
height: '180px',
|
||||
left: '0',
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
width: '320px',
|
||||
zIndex: '2147483647',
|
||||
});
|
||||
document.body.append(canvas);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEmbeddedMpvSupport(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<EmbeddedMpvSupport> {
|
||||
return app.mainWindow.evaluate(async () => {
|
||||
return window.electron.getEmbeddedMpvSupport();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestSession(
|
||||
app: LaunchedElectronApp,
|
||||
sessionId: string
|
||||
): Promise<EmbeddedMpvSession | null> {
|
||||
return app.mainWindow.evaluate((id) => {
|
||||
const sessions =
|
||||
window.__packagedEmbeddedMpvSessions?.filter(
|
||||
(session) => session.id === id
|
||||
) ?? [];
|
||||
return sessions.at(-1) ?? null;
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
export function isMeaningfulNativePlaybackSnapshot(
|
||||
snapshot: {
|
||||
durationSeconds: number | null;
|
||||
error?: string;
|
||||
status: string;
|
||||
streamUrl: string;
|
||||
} | null,
|
||||
expectedUrl: string
|
||||
): boolean {
|
||||
if (
|
||||
!snapshot ||
|
||||
snapshot.error ||
|
||||
!['paused', 'playing'].includes(snapshot.status) ||
|
||||
typeof snapshot.durationSeconds !== 'number' ||
|
||||
!Number.isFinite(snapshot.durationSeconds) ||
|
||||
snapshot.durationSeconds <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizeUrl = (value: string): string => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.hash = '';
|
||||
return url.href;
|
||||
} catch {
|
||||
return value.trim();
|
||||
}
|
||||
};
|
||||
|
||||
return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl);
|
||||
}
|
||||
|
||||
export async function renderedFrameSignal(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<number> {
|
||||
const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame');
|
||||
const png = await canvas.screenshot();
|
||||
const { data, info } = await sharp(png)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let signal = 0;
|
||||
|
||||
for (let offset = 0; offset < data.length; offset += info.channels) {
|
||||
if (data[offset] + data[offset + 1] + data[offset + 2] > 30) {
|
||||
signal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return signal;
|
||||
}
|
||||
|
||||
export async function closeAndWaitForExit(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
const processHandle = app.electronApp.process();
|
||||
await closeElectronApp(app);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
processHandle.exitCode !== null ||
|
||||
processHandle.signalCode !== null,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function cleanupPackagedFrameCopySmoke(options: {
|
||||
apps: Array<LaunchedElectronApp | undefined>;
|
||||
hiddenRuntimeEntry?: PackagedEntryGuard;
|
||||
media?: LocalMediaServer;
|
||||
packageClone?: DisposablePackagedLinuxApp;
|
||||
}): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (const app of options.apps) {
|
||||
if (!app) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await closeAndWaitForExit(app);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.hiddenRuntimeEntry) {
|
||||
try {
|
||||
options.hiddenRuntimeEntry.restore();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.media) {
|
||||
try {
|
||||
await options.media.close();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.packageClone) {
|
||||
try {
|
||||
options.packageClone.cleanup();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Packaged frame-copy smoke cleanup failed: ${errors
|
||||
.map((error) =>
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
.join('; ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
expect,
|
||||
launchPackagedElectronApp,
|
||||
resolvePackagedLinuxExecutable,
|
||||
test,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
assertNativeFallbackPrerequisites,
|
||||
cleanupPackagedFrameCopySmoke,
|
||||
closeAndWaitForExit,
|
||||
createLocalMediaServer,
|
||||
getEmbeddedMpvSupport,
|
||||
getLatestSession,
|
||||
installEmbeddedMpvSessionCapture,
|
||||
installFrameCanvasAndSessionCapture,
|
||||
isMeaningfulNativePlaybackSnapshot,
|
||||
renderedFrameSignal,
|
||||
type LocalMediaServer,
|
||||
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
||||
import {
|
||||
createDisposablePackagedLinuxApp,
|
||||
createPackagedEntryGuard,
|
||||
readPackagedRuntimeIdentity,
|
||||
type DisposablePackagedLinuxApp,
|
||||
type PackagedEntryGuard,
|
||||
} from './embedded-mpv-frame-copy-packaged-filesystem';
|
||||
|
||||
const PACKAGED_FRAME_COPY_REQUIRED_ENV =
|
||||
'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY';
|
||||
const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
|
||||
const packagedExecutable = resolvePackagedLinuxExecutable();
|
||||
const packagedFrameCopyRequired = isTruthy(
|
||||
process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV]
|
||||
);
|
||||
|
||||
function isTruthy(value: string | undefined): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
// This smoke drives the public preload API directly so it exercises the real
|
||||
// packaged main process, helper, frame reader, WebGL pump, and pause controls
|
||||
// without coupling runtime validation to playlist import/settings UI state.
|
||||
test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
|
||||
test.skip(
|
||||
process.platform !== 'linux',
|
||||
'The packaged frame-copy runtime is Linux-only.'
|
||||
);
|
||||
test.skip(
|
||||
!packagedExecutable && !packagedFrameCopyRequired,
|
||||
`Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.`
|
||||
);
|
||||
|
||||
test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
expect(
|
||||
process.arch,
|
||||
'The official Linux frame-copy runtime is x64-only.'
|
||||
).toBe('x64');
|
||||
expect(
|
||||
packagedExecutable,
|
||||
`The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.`
|
||||
).toBeTruthy();
|
||||
|
||||
const sourceExecutablePath = packagedExecutable as string;
|
||||
assertNativeFallbackPrerequisites();
|
||||
let packageClone: DisposablePackagedLinuxApp | undefined;
|
||||
let hiddenRuntimeEntry: PackagedEntryGuard | undefined;
|
||||
let media: LocalMediaServer | undefined;
|
||||
let frameCopyApp: LaunchedElectronApp | undefined;
|
||||
let fallbackApp: LaunchedElectronApp | undefined;
|
||||
|
||||
try {
|
||||
packageClone =
|
||||
createDisposablePackagedLinuxApp(sourceExecutablePath);
|
||||
const executablePath = packageClone.executablePath;
|
||||
const nativeDir = packageClone.nativeDir;
|
||||
const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir);
|
||||
hiddenRuntimeEntry = createPackagedEntryGuard(
|
||||
join(nativeDir, 'lib', runtimeIdentity.libmpvSoname),
|
||||
{
|
||||
expectedKind: 'regular-file',
|
||||
hiddenDirectory: packageClone.temporaryRoot,
|
||||
}
|
||||
);
|
||||
const mediaServer = await createLocalMediaServer();
|
||||
media = mediaServer;
|
||||
|
||||
expect(runtimeIdentity).toMatchObject({
|
||||
arch: 'x64',
|
||||
platform: 'linux',
|
||||
runtimeMode: 'bundled',
|
||||
});
|
||||
expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile);
|
||||
|
||||
const launchedFrameCopyApp = await launchPackagedElectronApp(
|
||||
executablePath,
|
||||
dataDir,
|
||||
{
|
||||
env: {
|
||||
[FRAME_COPY_OPT_IN_ENV]: '1',
|
||||
},
|
||||
}
|
||||
);
|
||||
frameCopyApp = launchedFrameCopyApp;
|
||||
|
||||
await expect
|
||||
.poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp))
|
||||
.toMatchObject({
|
||||
engine: 'frame-copy',
|
||||
frameCopyAvailable: true,
|
||||
platform: 'linux',
|
||||
supported: true,
|
||||
});
|
||||
|
||||
await installFrameCanvasAndSessionCapture(launchedFrameCopyApp);
|
||||
const created = await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async () => {
|
||||
return window.electron.createEmbeddedMpvSession(
|
||||
{ x: 0, y: 0, width: 320, height: 180 },
|
||||
'Packaged frame-copy smoke',
|
||||
0
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async ({ sessionId, streamUrl }) => {
|
||||
await window.electron.setEmbeddedMpvPaused(sessionId, true);
|
||||
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
||||
streamUrl,
|
||||
title: 'Two-second generated Y4M fixture',
|
||||
isLive: false,
|
||||
});
|
||||
},
|
||||
{ sessionId: created.id, streamUrl: mediaServer.url }
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => getLatestSession(launchedFrameCopyApp, created.id),
|
||||
{
|
||||
timeout: 15000,
|
||||
}
|
||||
)
|
||||
.toMatchObject({
|
||||
status: 'paused',
|
||||
streamUrl: mediaServer.url,
|
||||
videoHeight: 36,
|
||||
videoWidth: 64,
|
||||
});
|
||||
|
||||
const attached = await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async (sessionId) => {
|
||||
return window.electron.attachEmbeddedMpvFrameView?.(
|
||||
sessionId
|
||||
);
|
||||
},
|
||||
created.id
|
||||
);
|
||||
expect(attached).toBe(true);
|
||||
await expect(
|
||||
launchedFrameCopyApp.mainWindow.getByTestId(
|
||||
'packaged-embedded-mpv-frame'
|
||||
)
|
||||
).toHaveAttribute('width', '320');
|
||||
await expect(
|
||||
launchedFrameCopyApp.mainWindow.getByTestId(
|
||||
'packaged-embedded-mpv-frame'
|
||||
)
|
||||
).toHaveAttribute('height', '180');
|
||||
await expect
|
||||
.poll(() => renderedFrameSignal(launchedFrameCopyApp), {
|
||||
timeout: 15000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
(sessionId) =>
|
||||
window.electron.setEmbeddedMpvPaused(sessionId, false),
|
||||
created.id
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await getLatestSession(
|
||||
launchedFrameCopyApp,
|
||||
created.id
|
||||
)
|
||||
)?.status,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBe('playing');
|
||||
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
(sessionId) =>
|
||||
window.electron.setEmbeddedMpvPaused(sessionId, true),
|
||||
created.id
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await getLatestSession(
|
||||
launchedFrameCopyApp,
|
||||
created.id
|
||||
)
|
||||
)?.status,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.toBe('paused');
|
||||
await launchedFrameCopyApp.mainWindow.evaluate(
|
||||
async (sessionId) => {
|
||||
window.electron.detachEmbeddedMpvFrameView?.();
|
||||
await window.electron.disposeEmbeddedMpvSession(sessionId);
|
||||
window.__packagedEmbeddedMpvUnsubscribe?.();
|
||||
},
|
||||
created.id
|
||||
);
|
||||
|
||||
await closeAndWaitForExit(launchedFrameCopyApp);
|
||||
frameCopyApp = undefined;
|
||||
|
||||
hiddenRuntimeEntry.hide();
|
||||
expect(readPackagedRuntimeIdentity(nativeDir)).toEqual(
|
||||
runtimeIdentity
|
||||
);
|
||||
|
||||
const launchedFallbackApp = await launchPackagedElectronApp(
|
||||
executablePath,
|
||||
dataDir,
|
||||
{
|
||||
env: {
|
||||
[FRAME_COPY_OPT_IN_ENV]: '1',
|
||||
},
|
||||
}
|
||||
);
|
||||
fallbackApp = launchedFallbackApp;
|
||||
const fallbackSupport =
|
||||
await getEmbeddedMpvSupport(launchedFallbackApp);
|
||||
|
||||
expect(fallbackSupport).toMatchObject({
|
||||
engine: 'native',
|
||||
frameCopyAvailable: false,
|
||||
frameCopyUnavailableReason: 'runtime-library-missing',
|
||||
platform: 'linux',
|
||||
supported: true,
|
||||
});
|
||||
|
||||
await installEmbeddedMpvSessionCapture(launchedFallbackApp);
|
||||
const nativeSession = await launchedFallbackApp.mainWindow.evaluate(
|
||||
async () => {
|
||||
return window.electron.createEmbeddedMpvSession(
|
||||
{ x: 0, y: 0, width: 320, height: 180 },
|
||||
'Native-view fallback smoke',
|
||||
0
|
||||
);
|
||||
}
|
||||
);
|
||||
expect(nativeSession.id).toMatch(/^embedded-mpv-/);
|
||||
await launchedFallbackApp.mainWindow.evaluate(
|
||||
async ({ sessionId, streamUrl }) => {
|
||||
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
||||
streamUrl,
|
||||
title: 'Native-view generated Y4M fixture',
|
||||
isLive: false,
|
||||
});
|
||||
},
|
||||
{ sessionId: nativeSession.id, streamUrl: mediaServer.url }
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
isMeaningfulNativePlaybackSnapshot(
|
||||
await getLatestSession(
|
||||
launchedFallbackApp,
|
||||
nativeSession.id
|
||||
),
|
||||
mediaServer.url
|
||||
),
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.toBe(true);
|
||||
expect(
|
||||
launchedFallbackApp.electronApp.process().exitCode
|
||||
).toBeNull();
|
||||
expect(
|
||||
launchedFallbackApp.electronApp.process().signalCode
|
||||
).toBeNull();
|
||||
await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => {
|
||||
await window.electron.disposeEmbeddedMpvSession(sessionId);
|
||||
window.__packagedEmbeddedMpvUnsubscribe?.();
|
||||
}, nativeSession.id);
|
||||
await expect
|
||||
.poll(() => launchedFallbackApp.mainWindow.title())
|
||||
.toContain('IPTVnator');
|
||||
} finally {
|
||||
await cleanupPackagedFrameCopySmoke({
|
||||
apps: [frameCopyApp, fallbackApp],
|
||||
hiddenRuntimeEntry,
|
||||
media,
|
||||
packageClone,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
closeElectronApp,
|
||||
defaultStalkerMacAddress,
|
||||
expect,
|
||||
expectNoWorkspaceSearchStatus,
|
||||
expectWorkspaceSearchScope,
|
||||
expectWorkspaceSearchStatus,
|
||||
fillWorkspaceSearch,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
@@ -987,7 +987,7 @@ test.describe('Electron Workspace Search', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({
|
||||
test('@search @stalker searches the complete channel list on ITV routes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
@@ -1030,26 +1030,22 @@ test.describe('Electron Workspace Search', () => {
|
||||
app.mainWindow,
|
||||
`Live TV / ${sample.categoryName}`
|
||||
);
|
||||
await expectWorkspaceSearchStatus(
|
||||
app.mainWindow,
|
||||
'Loaded channels only'
|
||||
);
|
||||
// Entering Live TV preloads the COMPLETE channel list (Ministra
|
||||
// get_all_channels), so search runs locally over every channel:
|
||||
// no portal-side search request, and no "Loaded channels only"
|
||||
// degraded hint.
|
||||
await waitForPortalDebugEvent(app.mainWindow, {
|
||||
provider: 'stalker',
|
||||
operation: 'get_ordered_list',
|
||||
operation: 'get_all_channels',
|
||||
predicate: (event) => {
|
||||
const requestPayload = event.request as {
|
||||
params?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
return (
|
||||
requestPayload.params?.['type'] === 'itv' &&
|
||||
String(requestPayload.params?.['category']) ===
|
||||
sample.categoryId &&
|
||||
requestPayload.params?.['search'] === itvQuery
|
||||
);
|
||||
return requestPayload.params?.['type'] === 'itv';
|
||||
},
|
||||
});
|
||||
await expectNoWorkspaceSearchStatus(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
@@ -10,6 +11,25 @@ const {
|
||||
const {
|
||||
removeStaleFrameCopyArtifacts,
|
||||
} = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs');
|
||||
const {
|
||||
isLinuxSystemBuildInputManifest,
|
||||
validateLinuxRuntimeManifest,
|
||||
validateLinuxSystemBuildInputManifest,
|
||||
} = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs');
|
||||
const {
|
||||
SOURCE_ARCHIVE_BINDING_NAME,
|
||||
validateLinuxSourceArchiveBinding,
|
||||
} = require('../../tools/embedded-mpv/linux-source-archive-contract.cjs');
|
||||
const {
|
||||
resolveLinuxFrameCopyLinkageInputs,
|
||||
resolveVerifiedLinuxLibMpvSoname,
|
||||
runWithCleanup,
|
||||
validateLinuxFrameCopyLinkage,
|
||||
} = require('./embedded-mpv-linux-linkage.cjs');
|
||||
|
||||
const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);
|
||||
const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl';
|
||||
const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build';
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const addonRoot = path.join(
|
||||
@@ -109,6 +129,158 @@ function readRuntimeManifest(runtimeRoot) {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
|
||||
function readLinuxSourceArchiveBinding(runtimeRoot, errors) {
|
||||
const bindingPath = path.join(runtimeRoot, SOURCE_ARCHIVE_BINDING_NAME);
|
||||
if (!fs.existsSync(bindingPath)) {
|
||||
return null;
|
||||
}
|
||||
let binding;
|
||||
try {
|
||||
const stat = fs.lstatSync(bindingPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error('binding must be a regular file');
|
||||
}
|
||||
binding = JSON.parse(fs.readFileSync(bindingPath, 'utf8'));
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`source archive binding is invalid: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
errors.push(
|
||||
...validateLinuxSourceArchiveBinding(binding).map(
|
||||
(error) => `source archive binding is invalid: ${error}`
|
||||
)
|
||||
);
|
||||
return binding;
|
||||
}
|
||||
|
||||
function validatedLinuxSourceRuntime(runtimeRoot) {
|
||||
const stagedManifest = readRuntimeManifest(runtimeRoot);
|
||||
if (
|
||||
stagedManifest === null ||
|
||||
typeof stagedManifest !== 'object' ||
|
||||
Array.isArray(stagedManifest)
|
||||
) {
|
||||
throw new Error(
|
||||
`Staged Linux runtime manifest must be an object: ${path.join(
|
||||
runtimeRoot,
|
||||
'runtime-manifest.json'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const envelopeErrors = [];
|
||||
if (stagedManifest.origin !== LINUX_STAGED_RUNTIME_ORIGIN) {
|
||||
envelopeErrors.push(`origin must be "${LINUX_STAGED_RUNTIME_ORIGIN}"`);
|
||||
}
|
||||
if (stagedManifest.platform !== 'linux') {
|
||||
envelopeErrors.push('platform must be "linux"');
|
||||
}
|
||||
if (stagedManifest.arch !== targetArch) {
|
||||
envelopeErrors.push(`arch must be "${targetArch}"`);
|
||||
}
|
||||
if (
|
||||
typeof stagedManifest.stagedAt !== 'string' ||
|
||||
stagedManifest.stagedAt.trim().length === 0
|
||||
) {
|
||||
envelopeErrors.push('stagedAt must be a non-empty string');
|
||||
}
|
||||
if (!Array.isArray(stagedManifest.runtimeFiles)) {
|
||||
envelopeErrors.push('runtimeFiles must be an array');
|
||||
}
|
||||
|
||||
const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest);
|
||||
let buildInputMode;
|
||||
let sourceArchive = null;
|
||||
let sourceRuntimeManifest;
|
||||
if (systemBuildInputs) {
|
||||
buildInputMode = 'system-build-inputs';
|
||||
sourceRuntimeManifest = {
|
||||
linuxBackend: stagedManifest.linuxBackend,
|
||||
buildInputs: stagedManifest.buildInputs,
|
||||
sourceDistribution: stagedManifest.sourceDistribution,
|
||||
};
|
||||
if (
|
||||
Array.isArray(stagedManifest.runtimeFiles) &&
|
||||
stagedManifest.runtimeFiles.length !== 0
|
||||
) {
|
||||
envelopeErrors.push(
|
||||
'system build inputs must not declare staged runtime files'
|
||||
);
|
||||
}
|
||||
if (stagedManifest.sourceBuildOrigin !== undefined) {
|
||||
envelopeErrors.push(
|
||||
'system build inputs must not declare sourceBuildOrigin'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
buildInputMode = 'bundled-runtime';
|
||||
sourceArchive = readLinuxSourceArchiveBinding(
|
||||
runtimeRoot,
|
||||
envelopeErrors
|
||||
);
|
||||
const sourceBuildOrigin = stagedManifest.sourceBuildOrigin;
|
||||
const sourceMetadata = { ...stagedManifest };
|
||||
delete sourceMetadata.sourceBuildOrigin;
|
||||
delete sourceMetadata.stagedAt;
|
||||
sourceRuntimeManifest = {
|
||||
...sourceMetadata,
|
||||
origin: sourceBuildOrigin,
|
||||
};
|
||||
if (sourceBuildOrigin !== LINUX_SOURCE_RUNTIME_ORIGIN) {
|
||||
envelopeErrors.push(
|
||||
`sourceBuildOrigin must be "${LINUX_SOURCE_RUNTIME_ORIGIN}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceManifestErrors =
|
||||
buildInputMode === 'system-build-inputs'
|
||||
? validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)
|
||||
: validateLinuxRuntimeManifest(sourceRuntimeManifest);
|
||||
const declaredRuntimeFileNames = Array.isArray(
|
||||
sourceRuntimeManifest.runtimeFiles
|
||||
)
|
||||
? sourceRuntimeManifest.runtimeFiles
|
||||
.map((runtimeFile) => runtimeFile.name)
|
||||
.sort()
|
||||
: [];
|
||||
const stagedRuntimeFileNames = listRuntimeFiles(
|
||||
path.join(runtimeRoot, 'lib'),
|
||||
runtimeFilePredicate
|
||||
)
|
||||
.map((runtimeFile) => path.basename(runtimeFile))
|
||||
.sort();
|
||||
if (
|
||||
JSON.stringify(stagedRuntimeFileNames) !==
|
||||
JSON.stringify(declaredRuntimeFileNames)
|
||||
) {
|
||||
envelopeErrors.push(
|
||||
'staged lib directory must exactly match manifest runtimeFiles'
|
||||
);
|
||||
}
|
||||
|
||||
const errors = [...envelopeErrors, ...sourceManifestErrors];
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Invalid staged Linux runtime at ${runtimeRoot}:`,
|
||||
...errors.map((error) => `- ${error}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
buildInputMode,
|
||||
sourceArchive,
|
||||
sourceRuntimeManifest,
|
||||
sourceRuntimeValidated: buildInputMode === 'bundled-runtime',
|
||||
};
|
||||
}
|
||||
|
||||
function fileExists(filePath) {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
}
|
||||
@@ -183,9 +355,9 @@ function findWindowsLibMpv(runtimeRoot) {
|
||||
}
|
||||
|
||||
/* Debian/Ubuntu install linker targets under the multiarch triple dir. The
|
||||
* compiler's built-in search paths cover it for -l resolution either way;
|
||||
* this keeps the -L flag and the helper's baked rpath pointing somewhere
|
||||
* real. */
|
||||
* compiler's built-in search paths cover it for -l resolution either way.
|
||||
* This directory is a link-time input only; the helper runtime intentionally
|
||||
* stays on the sanitized system loader contract. */
|
||||
function defaultLinuxSystemLibDir() {
|
||||
const multiarchTriples = {
|
||||
arm: 'arm-linux-gnueabihf',
|
||||
@@ -211,15 +383,19 @@ function findLinuxLibMpv(libDir) {
|
||||
}
|
||||
|
||||
function resolveRuntime() {
|
||||
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
|
||||
const stagedLinuxRuntime =
|
||||
targetPlatform === 'linux' && fs.existsSync(vendoredHeader)
|
||||
? validatedLinuxSourceRuntime(vendoredRuntimeRoot)
|
||||
: null;
|
||||
const vendoredLibMpv =
|
||||
targetPlatform === 'darwin'
|
||||
? findLibMpv(vendoredLibDir)
|
||||
: targetPlatform === 'win32'
|
||||
? findWindowsLibMpv(vendoredRuntimeRoot)
|
||||
: targetPlatform === 'linux'
|
||||
? true
|
||||
? stagedLinuxRuntime
|
||||
: null;
|
||||
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
|
||||
|
||||
if (vendoredLibMpv && fs.existsSync(vendoredHeader)) {
|
||||
const windowsImportLib =
|
||||
@@ -237,17 +413,23 @@ function resolveRuntime() {
|
||||
binDir: vendoredBinDir,
|
||||
manifest: readRuntimeManifest(vendoredRuntimeRoot),
|
||||
windowsImportLib,
|
||||
...(stagedLinuxRuntime ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetPlatform === 'linux') {
|
||||
// Dev-first Linux flow (frame-copy helper links system libmpv): a
|
||||
// distro libmpv-dev install is a full runtime — no staging needed.
|
||||
// LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system
|
||||
// paths for machines with a local (non-root) libmpv prefix.
|
||||
// LIBMPV_INCLUDE_DIR overrides the header path.
|
||||
// LINUX_NATIVE_LIBRARY_DIR overrides the link-time library directory,
|
||||
// which must already be visible to the system dynamic loader.
|
||||
const systemIncludeDir =
|
||||
process.env.LIBMPV_INCLUDE_DIR || '/usr/include';
|
||||
if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) {
|
||||
const sourceRuntimeManifest = {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
warning: 'Development-only unmanaged system libmpv toolchain.',
|
||||
};
|
||||
return {
|
||||
origin: 'system-dev',
|
||||
includeDir: systemIncludeDir,
|
||||
@@ -255,10 +437,10 @@ function resolveRuntime() {
|
||||
process.env.LINUX_NATIVE_LIBRARY_DIR ||
|
||||
defaultLinuxSystemLibDir(),
|
||||
binDir: undefined,
|
||||
manifest: {
|
||||
warning:
|
||||
'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.',
|
||||
},
|
||||
manifest: sourceRuntimeManifest,
|
||||
buildInputMode: 'system-dev',
|
||||
sourceRuntimeManifest,
|
||||
sourceRuntimeValidated: false,
|
||||
windowsImportLib: null,
|
||||
};
|
||||
}
|
||||
@@ -288,19 +470,141 @@ function resolveRuntime() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeLinuxProcessRuntimeManifest(runtime) {
|
||||
function hasStagedLinuxLibMpvLinkerInput(runtime) {
|
||||
return (
|
||||
Array.isArray(runtime.sourceRuntimeManifest?.runtimeFiles) &&
|
||||
runtime.sourceRuntimeManifest.runtimeFiles.some(
|
||||
(runtimeFile) => runtimeFile.name === 'libmpv.so'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function assertRequiredLinuxFrameCopyRuntime(runtime) {
|
||||
if (targetPlatform !== 'linux' || !embeddedMpvRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
runtime.buildInputMode !== 'bundled-runtime' ||
|
||||
runtime.sourceRuntimeValidated !== true ||
|
||||
!runtime.sourceArchive ||
|
||||
!hasStagedLinuxLibMpvLinkerInput(runtime)
|
||||
) {
|
||||
cleanOutput();
|
||||
throw new Error(
|
||||
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function copyLinuxRuntimeClosureToNativeBuild(runtime) {
|
||||
fs.rmSync(outputLibDir, { recursive: true, force: true });
|
||||
|
||||
const declaredRuntimeFiles =
|
||||
runtime.buildInputMode === 'bundled-runtime'
|
||||
? runtime.sourceRuntimeManifest.runtimeFiles
|
||||
: [];
|
||||
if (declaredRuntimeFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputLibDir, { recursive: true });
|
||||
const copiedRuntimeFiles = [];
|
||||
for (const runtimeFile of declaredRuntimeFiles) {
|
||||
const sourcePath = path.join(runtime.libDir, runtimeFile.name);
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
sourcePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW
|
||||
);
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(
|
||||
`Staged Linux runtime path is not a regular file: ${sourcePath}`
|
||||
);
|
||||
}
|
||||
|
||||
const contents = fs.readFileSync(descriptor);
|
||||
if (contents.byteLength !== runtimeFile.size) {
|
||||
throw new Error(
|
||||
`Size mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.`
|
||||
);
|
||||
}
|
||||
const actualSha256 = crypto
|
||||
.createHash('sha256')
|
||||
.update(contents)
|
||||
.digest('hex');
|
||||
if (actualSha256 !== runtimeFile.sha256) {
|
||||
throw new Error(
|
||||
`SHA-256 mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
|
||||
);
|
||||
}
|
||||
|
||||
const destinationPath = path.join(outputLibDir, runtimeFile.name);
|
||||
fs.writeFileSync(destinationPath, contents, { mode: 0o755 });
|
||||
copiedRuntimeFiles.push({ ...runtimeFile });
|
||||
} finally {
|
||||
if (descriptor !== undefined) {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copiedRuntimeFiles;
|
||||
}
|
||||
|
||||
function writeLinuxFrameCopyBuildManifest(runtime) {
|
||||
const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime);
|
||||
const libmpvSoname =
|
||||
runtime.sourceRuntimeValidated === true &&
|
||||
runtime.buildInputMode === 'bundled-runtime' &&
|
||||
copiedRuntimeFiles.length > 0
|
||||
? resolveVerifiedLinuxLibMpvSoname({
|
||||
outputLibDir,
|
||||
runtimeFiles: copiedRuntimeFiles,
|
||||
runtimeDependencyClosure:
|
||||
runtime.sourceRuntimeManifest.runtimeDependencyClosure,
|
||||
readDynamicSection: readLinuxDynamicSection,
|
||||
})
|
||||
: null;
|
||||
const packageRuntimeAvailable =
|
||||
runtime.sourceRuntimeValidated === true &&
|
||||
runtime.buildInputMode === 'bundled-runtime' &&
|
||||
copiedRuntimeFiles.length > 0 &&
|
||||
libmpvSoname !== null;
|
||||
const manifest = {
|
||||
...runtime.manifest,
|
||||
origin: 'external-mpv-process',
|
||||
schemaVersion: 1,
|
||||
origin: 'linux-frame-copy-build',
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeFiles: [],
|
||||
linuxBackend:
|
||||
runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid',
|
||||
mpvExecutable: 'mpv',
|
||||
platform: targetPlatform,
|
||||
targetArch,
|
||||
arch: targetArch,
|
||||
buildInputMode: runtime.buildInputMode,
|
||||
sourceRuntimeValidated: runtime.sourceRuntimeValidated,
|
||||
allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES],
|
||||
packageRuntimeAvailability: {
|
||||
system: packageRuntimeAvailable,
|
||||
bundled: packageRuntimeAvailable,
|
||||
},
|
||||
artifacts: {
|
||||
addon: 'embedded_mpv.node',
|
||||
frameReader: 'embedded_mpv_frame_reader.node',
|
||||
helper: 'iptvnator_mpv_helper',
|
||||
},
|
||||
processIsolation: {
|
||||
addonLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
},
|
||||
nativeViewFallback: 'process-isolated mpv --wid',
|
||||
libmpvSoname,
|
||||
runtimeFiles: copiedRuntimeFiles,
|
||||
runtimeTotalBytes: copiedRuntimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
),
|
||||
sourceArchive: runtime.sourceArchive ?? null,
|
||||
sourceRuntime: runtime.sourceRuntimeManifest,
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -428,12 +732,33 @@ function runNodeGyp(command, env) {
|
||||
}
|
||||
}
|
||||
|
||||
function readLinuxDynamicSection(filePath) {
|
||||
const result = spawnSync('readelf', ['-d', filePath], {
|
||||
cwd: workspaceRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Unable to run readelf for ${filePath}: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`readelf -d failed for ${filePath} with status ${
|
||||
result.status ?? 1
|
||||
}: ${(result.stderr ?? '').trim()}`
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
cleanDistNativeOutput();
|
||||
cleanOutput();
|
||||
|
||||
if (targetPlatform !== process.platform) {
|
||||
cleanOutput();
|
||||
if (embeddedMpvRequired) {
|
||||
throw new Error(
|
||||
`Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.`
|
||||
@@ -465,58 +790,114 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeManifest =
|
||||
targetPlatform === 'darwin'
|
||||
? copyRuntimeToNativeBuild({
|
||||
runtimeLibDir: runtime.libDir,
|
||||
outputLibDir,
|
||||
runtimeOrigin: runtime.origin,
|
||||
runtimeManifest: {
|
||||
targetArch,
|
||||
...runtime.manifest,
|
||||
},
|
||||
})
|
||||
: targetPlatform === 'linux'
|
||||
? writeLinuxProcessRuntimeManifest(runtime)
|
||||
: copyGenericRuntimeToNativeBuild(runtime);
|
||||
fs.rmSync(unavailableMarkerFile, { force: true });
|
||||
assertRequiredLinuxFrameCopyRuntime(runtime);
|
||||
|
||||
const electronPackageJson = require(
|
||||
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
|
||||
);
|
||||
const electronVersion = electronPackageJson.version;
|
||||
const env = {
|
||||
...process.env,
|
||||
npm_config_runtime: 'electron',
|
||||
npm_config_target: electronVersion,
|
||||
npm_config_arch: targetArch,
|
||||
npm_config_disturl: 'https://electronjs.org/headers',
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_update_binary: 'false',
|
||||
LIBMPV_INCLUDE_DIR: runtime.includeDir,
|
||||
...(targetPlatform === 'linux'
|
||||
? {
|
||||
LINUX_NATIVE_LIBRARY_DIR:
|
||||
process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir,
|
||||
}
|
||||
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
|
||||
...(runtime.windowsImportLib
|
||||
? {
|
||||
LIBMPV_IMPORT_LIB: path.join(
|
||||
const buildNativeArtifacts = () => {
|
||||
const runtimeManifest =
|
||||
targetPlatform === 'darwin'
|
||||
? copyRuntimeToNativeBuild({
|
||||
runtimeLibDir: runtime.libDir,
|
||||
outputLibDir,
|
||||
path.basename(runtime.windowsImportLib)
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
runtimeOrigin: runtime.origin,
|
||||
runtimeManifest: {
|
||||
targetArch,
|
||||
...runtime.manifest,
|
||||
},
|
||||
})
|
||||
: targetPlatform === 'linux'
|
||||
? writeLinuxFrameCopyBuildManifest(runtime)
|
||||
: copyGenericRuntimeToNativeBuild(runtime);
|
||||
const linuxLinkageInputs =
|
||||
targetPlatform === 'linux'
|
||||
? resolveLinuxFrameCopyLinkageInputs({
|
||||
buildInputMode: runtime.buildInputMode,
|
||||
outputLibDir,
|
||||
packagedLibmpvSoname: runtimeManifest.libmpvSoname,
|
||||
readDynamicSection: readLinuxDynamicSection,
|
||||
runtimeLibDir: runtime.libDir,
|
||||
})
|
||||
: null;
|
||||
|
||||
log(
|
||||
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
|
||||
);
|
||||
cleanNativeBuildIntermediates();
|
||||
try {
|
||||
const electronPackageJson = require(
|
||||
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
|
||||
);
|
||||
const electronVersion = electronPackageJson.version;
|
||||
const env = {
|
||||
...process.env,
|
||||
npm_config_runtime: 'electron',
|
||||
npm_config_target: electronVersion,
|
||||
npm_config_arch: targetArch,
|
||||
npm_config_disturl: 'https://electronjs.org/headers',
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_update_binary: 'false',
|
||||
LIBMPV_INCLUDE_DIR: runtime.includeDir,
|
||||
...(targetPlatform === 'linux'
|
||||
? {
|
||||
LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:
|
||||
linuxLinkageInputs.linkerLibraryDir,
|
||||
}
|
||||
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
|
||||
...(runtime.windowsImportLib
|
||||
? {
|
||||
LIBMPV_IMPORT_LIB: path.join(
|
||||
outputLibDir,
|
||||
path.basename(runtime.windowsImportLib)
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
log(
|
||||
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
|
||||
);
|
||||
cleanNativeBuildIntermediates();
|
||||
runNodeGyp('configure', env);
|
||||
runNodeGyp('build', env);
|
||||
|
||||
if (!fs.existsSync(outputFile)) {
|
||||
throw new Error(`Build finished without producing ${outputFile}.`);
|
||||
}
|
||||
|
||||
if (targetPlatform === 'linux') {
|
||||
validateLinuxFrameCopyLinkage({
|
||||
expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname,
|
||||
outputDir,
|
||||
readDynamicSection: readLinuxDynamicSection,
|
||||
});
|
||||
}
|
||||
|
||||
if (targetPlatform === 'darwin') {
|
||||
patchAddonForBundledRuntime(outputFile, outputLibDir);
|
||||
// The frame-copy helper executable links libmpv too and sits next
|
||||
// to the same lib/ directory, so it gets the identical
|
||||
// dependency-path rewrite + ad-hoc re-sign.
|
||||
const frameHelperFile = path.join(
|
||||
outputDir,
|
||||
'iptvnator_mpv_helper'
|
||||
);
|
||||
if (fs.existsSync(frameHelperFile)) {
|
||||
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
|
||||
}
|
||||
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
|
||||
outputFile,
|
||||
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
|
||||
...runtimeManifest.dylibs.map((dylib) =>
|
||||
path.join(outputLibDir, dylib)
|
||||
),
|
||||
]);
|
||||
if (
|
||||
runtime.origin === 'vendored-lgpl' &&
|
||||
forbiddenLinkErrors.length > 0
|
||||
) {
|
||||
throw new Error(forbiddenLinkErrors.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(unavailableMarkerFile, { force: true });
|
||||
};
|
||||
|
||||
try {
|
||||
runWithCleanup(buildNativeArtifacts, cleanOutput);
|
||||
} catch (error) {
|
||||
if (!embeddedMpvRequired && runtime.origin === 'system-dev') {
|
||||
// The system-dev fallback triggers on any machine with
|
||||
@@ -528,40 +909,11 @@ function main() {
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
cleanOutput();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(outputFile)) {
|
||||
throw new Error(`Build finished without producing ${outputFile}.`);
|
||||
}
|
||||
|
||||
if (targetPlatform === 'darwin') {
|
||||
patchAddonForBundledRuntime(outputFile, outputLibDir);
|
||||
// The frame-copy helper executable links libmpv too and sits next to
|
||||
// the same lib/ directory, so it gets the identical dependency-path
|
||||
// rewrite + ad-hoc re-sign.
|
||||
const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper');
|
||||
if (fs.existsSync(frameHelperFile)) {
|
||||
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
|
||||
}
|
||||
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
|
||||
outputFile,
|
||||
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
|
||||
...runtimeManifest.dylibs.map((dylib) =>
|
||||
path.join(outputLibDir, dylib)
|
||||
),
|
||||
]);
|
||||
if (
|
||||
runtime.origin === 'vendored-lgpl' &&
|
||||
forbiddenLinkErrors.length > 0
|
||||
) {
|
||||
throw new Error(forbiddenLinkErrors.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
log(`Built ${path.relative(workspaceRoot, outputFile)}.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
|
||||
const LIBMPV_NEEDED_PATTERN = /^libmpv\.so(?:\..*)?$/;
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
const LINUX_FRAME_COPY_ARTIFACTS = Object.freeze([
|
||||
Object.freeze({
|
||||
fileName: 'embedded_mpv.node',
|
||||
label: 'embedded MPV addon',
|
||||
mayLinkLibmpv: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
fileName: 'embedded_mpv_frame_reader.node',
|
||||
label: 'embedded MPV frame reader',
|
||||
mayLinkLibmpv: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
fileName: 'iptvnator_mpv_helper',
|
||||
label: 'embedded MPV frame-copy helper',
|
||||
mayLinkLibmpv: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
function parseReadelfDynamic(output) {
|
||||
if (typeof output !== 'string') {
|
||||
throw new TypeError('readelf dynamic output must be a string.');
|
||||
}
|
||||
|
||||
const dynamic = {
|
||||
needed: [],
|
||||
rpath: [],
|
||||
runpath: [],
|
||||
soname: [],
|
||||
};
|
||||
const dynamicEntryPattern =
|
||||
/\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g;
|
||||
for (const [, tag, value] of output.matchAll(dynamicEntryPattern)) {
|
||||
if (tag === 'NEEDED') {
|
||||
dynamic.needed.push(value);
|
||||
continue;
|
||||
}
|
||||
if (tag === 'SONAME') {
|
||||
dynamic.soname.push(value);
|
||||
continue;
|
||||
}
|
||||
dynamic[tag.toLowerCase()].push(
|
||||
...value.split(':').filter((entry) => entry.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
return dynamic;
|
||||
}
|
||||
|
||||
function exactlyOneRuntimeFile(runtimeFiles, name) {
|
||||
if (!Array.isArray(runtimeFiles)) {
|
||||
throw new Error('Linux runtimeFiles metadata must be an array.');
|
||||
}
|
||||
const matchingRecords = runtimeFiles.filter(
|
||||
(runtimeFile) => runtimeFile?.name === name
|
||||
);
|
||||
if (matchingRecords.length !== 1) {
|
||||
throw new Error(
|
||||
`Linux runtime must contain exactly one exact runtimeFiles record for ${name}.`
|
||||
);
|
||||
}
|
||||
|
||||
const [runtimeFile] = matchingRecords;
|
||||
if (
|
||||
!Number.isInteger(runtimeFile.size) ||
|
||||
runtimeFile.size <= 0 ||
|
||||
typeof runtimeFile.sha256 !== 'string' ||
|
||||
!SHA256_PATTERN.test(runtimeFile.sha256)
|
||||
) {
|
||||
throw new Error(
|
||||
`Linux runtimeFiles record for ${name} has invalid size or SHA-256 metadata.`
|
||||
);
|
||||
}
|
||||
return runtimeFile;
|
||||
}
|
||||
|
||||
function readVerifiedRuntimeFile(filePath, runtimeFile) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
throw new Error(`Missing copied Linux runtime file: ${filePath}`);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Copied Linux runtime file must be a regular non-symbolic-link file: ${filePath}`
|
||||
);
|
||||
}
|
||||
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
|
||||
);
|
||||
const descriptorStat = fs.fstatSync(descriptor);
|
||||
if (!descriptorStat.isFile()) {
|
||||
throw new Error(
|
||||
`Copied Linux runtime path is not a regular file: ${filePath}`
|
||||
);
|
||||
}
|
||||
const contents = fs.readFileSync(descriptor);
|
||||
if (contents.byteLength !== runtimeFile.size) {
|
||||
throw new Error(
|
||||
`Size mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.`
|
||||
);
|
||||
}
|
||||
const actualSha256 = crypto
|
||||
.createHash('sha256')
|
||||
.update(contents)
|
||||
.digest('hex');
|
||||
if (actualSha256 !== runtimeFile.sha256) {
|
||||
throw new Error(
|
||||
`SHA-256 mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== undefined) {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closureLibMpvSoname(runtimeDependencyClosure) {
|
||||
if (!Array.isArray(runtimeDependencyClosure?.entries)) {
|
||||
throw new Error(
|
||||
'Validated Linux runtime dependency closure entries are required.'
|
||||
);
|
||||
}
|
||||
|
||||
const libMpvEntries = runtimeDependencyClosure.entries.filter(
|
||||
(entry) =>
|
||||
entry?.name === 'libmpv.so' ||
|
||||
VERSIONED_LIBMPV_PATTERN.test(entry?.name)
|
||||
);
|
||||
const declaredSonames = libMpvEntries.map((entry) => entry.soname);
|
||||
const uniqueSonames = new Set(declaredSonames);
|
||||
if (
|
||||
declaredSonames.length === 0 ||
|
||||
declaredSonames.some(
|
||||
(soname) =>
|
||||
typeof soname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(soname)
|
||||
) ||
|
||||
uniqueSonames.size !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
'Validated Linux runtime closure must declare exactly one versioned libmpv SONAME.'
|
||||
);
|
||||
}
|
||||
|
||||
return declaredSonames[0];
|
||||
}
|
||||
|
||||
function resolveVerifiedLinuxLibMpvSoname({
|
||||
outputLibDir,
|
||||
runtimeFiles,
|
||||
runtimeDependencyClosure,
|
||||
readDynamicSection,
|
||||
}) {
|
||||
if (typeof readDynamicSection !== 'function') {
|
||||
throw new TypeError('Linux readelf dynamic reader is required.');
|
||||
}
|
||||
|
||||
const expectedSoname = closureLibMpvSoname(runtimeDependencyClosure);
|
||||
const linkerInputRecord = exactlyOneRuntimeFile(runtimeFiles, 'libmpv.so');
|
||||
const exactSonameRecord = exactlyOneRuntimeFile(
|
||||
runtimeFiles,
|
||||
expectedSoname
|
||||
);
|
||||
const linkerInputPath = path.join(outputLibDir, 'libmpv.so');
|
||||
const exactSonamePath = path.join(outputLibDir, expectedSoname);
|
||||
|
||||
readVerifiedRuntimeFile(linkerInputPath, linkerInputRecord);
|
||||
readVerifiedRuntimeFile(exactSonamePath, exactSonameRecord);
|
||||
|
||||
const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath));
|
||||
if (
|
||||
dynamic.soname.length !== 1 ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0])
|
||||
) {
|
||||
throw new Error(
|
||||
'Copied Linux libmpv.so must contain exactly one DT_SONAME with a versioned libmpv basename.'
|
||||
);
|
||||
}
|
||||
if (dynamic.soname[0] !== expectedSoname) {
|
||||
throw new Error(
|
||||
`Copied Linux libmpv.so DT_SONAME ${dynamic.soname[0]} does not match validated closure SONAME ${expectedSoname}.`
|
||||
);
|
||||
}
|
||||
|
||||
return expectedSoname;
|
||||
}
|
||||
|
||||
function resolveLinuxFrameCopyLinkageInputs({
|
||||
buildInputMode,
|
||||
outputLibDir,
|
||||
packagedLibmpvSoname,
|
||||
readDynamicSection,
|
||||
runtimeLibDir,
|
||||
}) {
|
||||
const systemDevelopment = buildInputMode === 'system-dev';
|
||||
const linkerLibraryDir = systemDevelopment ? runtimeLibDir : outputLibDir;
|
||||
if (
|
||||
typeof linkerLibraryDir !== 'string' ||
|
||||
linkerLibraryDir.trim().length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Linux frame-copy linkage requires a non-empty linker library directory.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemDevelopment) {
|
||||
return {
|
||||
expectedLibmpvSoname: packagedLibmpvSoname,
|
||||
linkerLibraryDir,
|
||||
};
|
||||
}
|
||||
if (typeof readDynamicSection !== 'function') {
|
||||
throw new TypeError('Linux readelf dynamic reader is required.');
|
||||
}
|
||||
|
||||
const linkerInputPath = path.join(linkerLibraryDir, 'libmpv.so');
|
||||
const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath));
|
||||
if (
|
||||
dynamic.soname.length !== 1 ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0])
|
||||
) {
|
||||
throw new Error(
|
||||
'The system-development libmpv linker input must contain exactly one versioned libmpv SONAME.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
expectedLibmpvSoname: dynamic.soname[0],
|
||||
linkerLibraryDir,
|
||||
};
|
||||
}
|
||||
|
||||
function assertRegularArtifact(filePath, label) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch {
|
||||
throw new Error(`Missing ${label}: ${filePath}`);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`${label} must be a regular non-symbolic-link file: ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateLinuxFrameCopyLinkage({
|
||||
expectedLibmpvSoname,
|
||||
outputDir,
|
||||
readDynamicSection,
|
||||
}) {
|
||||
if (
|
||||
typeof expectedLibmpvSoname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(expectedLibmpvSoname)
|
||||
) {
|
||||
throw new Error(
|
||||
'Linux frame-copy linkage validation requires an exact versioned libmpv SONAME.'
|
||||
);
|
||||
}
|
||||
if (typeof readDynamicSection !== 'function') {
|
||||
throw new TypeError('Linux readelf dynamic reader is required.');
|
||||
}
|
||||
|
||||
for (const artifact of LINUX_FRAME_COPY_ARTIFACTS) {
|
||||
const artifactPath = path.join(outputDir, artifact.fileName);
|
||||
assertRegularArtifact(artifactPath, artifact.label);
|
||||
const dynamic = parseReadelfDynamic(readDynamicSection(artifactPath));
|
||||
const libMpvDependencies = dynamic.needed.filter((dependency) =>
|
||||
LIBMPV_NEEDED_PATTERN.test(dependency)
|
||||
);
|
||||
|
||||
if (!artifact.mayLinkLibmpv) {
|
||||
if (libMpvDependencies.length > 0) {
|
||||
throw new Error(
|
||||
`${artifact.label} must not have a direct libmpv DT_NEEDED entry; found ${libMpvDependencies.join(', ')}.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
libMpvDependencies.length !== 1 ||
|
||||
libMpvDependencies[0] !== expectedLibmpvSoname
|
||||
) {
|
||||
throw new Error(
|
||||
`${artifact.label} DT_NEEDED must contain exactly ${expectedLibmpvSoname}; found ${
|
||||
libMpvDependencies.join(', ') || '<none>'
|
||||
}.`
|
||||
);
|
||||
}
|
||||
if (dynamic.rpath.length !== 0) {
|
||||
throw new Error(
|
||||
`${artifact.label} must not contain RPATH; found ${dynamic.rpath.join(':')}.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
dynamic.runpath.length !== 1 ||
|
||||
dynamic.runpath[0] !== '$ORIGIN/lib'
|
||||
) {
|
||||
throw new Error(
|
||||
`${artifact.label} RUNPATH must be exactly $ORIGIN/lib; found ${
|
||||
dynamic.runpath.join(':') || '<none>'
|
||||
}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runWithCleanup(operation, cleanup) {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseReadelfDynamic,
|
||||
resolveLinuxFrameCopyLinkageInputs,
|
||||
resolveVerifiedLinuxLibMpvSoname,
|
||||
runWithCleanup,
|
||||
validateLinuxFrameCopyLinkage,
|
||||
};
|
||||
@@ -158,14 +158,14 @@
|
||||
],
|
||||
"ldflags": [
|
||||
"-pthread",
|
||||
"-Wl,-rpath,'$$ORIGIN/lib'",
|
||||
"-Wl,-rpath,<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")"
|
||||
"-Wl,--enable-new-dtags",
|
||||
"-Wl,-rpath,'$$ORIGIN/lib'"
|
||||
],
|
||||
"libraries": [
|
||||
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
|
||||
"-lmpv",
|
||||
"-L<!(node -e \"const dir = process.env.LINUX_VERIFIED_RUNTIME_LIBRARY_DIR; if (!dir) { throw new Error('Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); } process.stdout.write(dir)\")",
|
||||
"<!(node -e \"const path = require('path'); const dir = process.env.LINUX_VERIFIED_RUNTIME_LIBRARY_DIR; if (!dir) { throw new Error('Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); } process.stdout.write(path.join(dir, 'libmpv.so'))\")",
|
||||
"-lEGL",
|
||||
"-lOpenGL",
|
||||
"-lGL",
|
||||
"-lgbm",
|
||||
"-ldl"
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* earlier software renderer is recreated only if no hardware tier works.
|
||||
* Each tier uses a desktop-GL 3.2 core context bound surfaceless (1x1 pbuffer
|
||||
* fallback for drivers without EGL_KHR_surfaceless_context). The helper's
|
||||
* own GL calls resolve at link time through libOpenGL (glvnd); mpv resolves
|
||||
* own GL calls resolve at link time through libGL (glvnd); mpv resolves
|
||||
* core symbols from that linked library and falls back to eglGetProcAddress
|
||||
* for extensions.
|
||||
*
|
||||
@@ -188,6 +188,7 @@ public:
|
||||
|
||||
GlGetProcAddressFn procLoader() const { return glDlsymGetProcAddress; }
|
||||
void* procLoaderCtx() const { return glDylib_; }
|
||||
const char* renderApiName() const { return "cgl"; }
|
||||
|
||||
private:
|
||||
CGLContextObj cgl_ = nullptr;
|
||||
@@ -337,6 +338,7 @@ public:
|
||||
|
||||
GlGetProcAddressFn procLoader() const { return eglWrapGetProcAddress; }
|
||||
void* procLoaderCtx() const { return nullptr; }
|
||||
const char* renderApiName() const { return "egl"; }
|
||||
|
||||
private:
|
||||
enum class DisplayTier { SurfacelessMesa, Default, Gbm };
|
||||
@@ -780,6 +782,7 @@ public:
|
||||
|
||||
GlGetProcAddressFn procLoader() const { return wglLoaderGetProcAddress; }
|
||||
void* procLoaderCtx() const { return opengl32_; }
|
||||
const char* renderApiName() const { return "wgl"; }
|
||||
|
||||
private:
|
||||
HWND window_ = nullptr;
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace {
|
||||
|
||||
using frame_helper::Command;
|
||||
using frame_helper::emitLine;
|
||||
using frame_helper::GlContext;
|
||||
using frame_helper::JsonWriter;
|
||||
using frame_helper::RenderPipeline;
|
||||
|
||||
@@ -612,6 +613,7 @@ struct HelperArgs {
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
double volume = 1;
|
||||
bool runtimeProbe = false;
|
||||
};
|
||||
|
||||
HelperArgs parseArgs(int argc, char** argv) {
|
||||
@@ -627,12 +629,136 @@ HelperArgs parseArgs(int argc, char** argv) {
|
||||
else if (arg == "--volume") args.volume = std::atof(next().c_str());
|
||||
else if (arg == "--hwdec") args.hwdec = next();
|
||||
else if (arg == "--audio-delay") args.audioDelay = next();
|
||||
else if (arg == "--runtime-probe") args.runtimeProbe = true;
|
||||
}
|
||||
args.width = std::max(16, args.width);
|
||||
args.height = std::max(16, args.height);
|
||||
return args;
|
||||
}
|
||||
|
||||
int runtimeProbeFailure(const std::string& reason,
|
||||
const std::string& detail = "") {
|
||||
JsonWriter writer;
|
||||
writer.num("protocol", 1)
|
||||
.boolean("usable", false)
|
||||
.str("reason", reason);
|
||||
if (!detail.empty()) writer.str("detail", detail);
|
||||
emitLine(writer.finish());
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string libmpvClientApiVersion() {
|
||||
const unsigned long version = mpv_client_api_version();
|
||||
return std::to_string(version >> 16) + "." +
|
||||
std::to_string(version & 0xffff);
|
||||
}
|
||||
|
||||
std::string runtimeProbeShmName() {
|
||||
#if defined(_WIN32)
|
||||
const uint64_t processId = (uint64_t)GetCurrentProcessId();
|
||||
#else
|
||||
const uint64_t processId = (uint64_t)getpid();
|
||||
#endif
|
||||
return "/impv-fc-runtime-probe-" + std::to_string(processId);
|
||||
}
|
||||
|
||||
/*
|
||||
* Bounded startup capability probe: initialize an idle libmpv client and
|
||||
* create the platform GL + mpv OpenGL render contexts, then create, validate,
|
||||
* and destroy a minimal shared-memory ring. It deliberately does not create
|
||||
* an FBO, open media, or enter either command loop.
|
||||
*/
|
||||
int runRuntimeProbe() {
|
||||
mpv_handle* mpv = mpv_create();
|
||||
if (!mpv) {
|
||||
return runtimeProbeFailure("mpv-create-failed");
|
||||
}
|
||||
|
||||
mpv_set_option_string(mpv, "vo", "libmpv");
|
||||
mpv_set_option_string(mpv, "idle", "yes");
|
||||
mpv_set_option_string(mpv, "input-default-bindings", "no");
|
||||
mpv_set_option_string(mpv, "osc", "no");
|
||||
const int initializeResult = mpv_initialize(mpv);
|
||||
if (initializeResult < 0) {
|
||||
const std::string error = mpv_error_string(initializeResult);
|
||||
mpv_destroy(mpv);
|
||||
return runtimeProbeFailure("mpv-initialize-failed", error);
|
||||
}
|
||||
|
||||
GlContext gl;
|
||||
std::string error;
|
||||
if (!gl.create(error)) {
|
||||
gl.destroy();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return runtimeProbeFailure("gl-context-create-failed", error);
|
||||
}
|
||||
if (!gl.makeCurrent(error)) {
|
||||
gl.destroy();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return runtimeProbeFailure("gl-context-bind-failed", error);
|
||||
}
|
||||
|
||||
mpv_opengl_init_params glInit = {
|
||||
gl.procLoader(),
|
||||
gl.procLoaderCtx(),
|
||||
};
|
||||
mpv_render_param renderParams[] = {
|
||||
{MPV_RENDER_PARAM_API_TYPE,
|
||||
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
||||
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||
};
|
||||
mpv_render_context* renderContext = nullptr;
|
||||
const int renderResult =
|
||||
mpv_render_context_create(&renderContext, mpv, renderParams);
|
||||
if (renderResult < 0 || !renderContext) {
|
||||
const std::string renderError =
|
||||
renderResult < 0 ? mpv_error_string(renderResult)
|
||||
: "render context unavailable";
|
||||
if (renderContext) mpv_render_context_free(renderContext);
|
||||
gl.destroy();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return runtimeProbeFailure("mpv-render-context-failed", renderError);
|
||||
}
|
||||
|
||||
frame_helper::ShmRing runtimeProbeRing;
|
||||
const std::string shmName = runtimeProbeShmName();
|
||||
if (!runtimeProbeRing.create(shmName, 16, 16, 1)) {
|
||||
runtimeProbeRing.destroy();
|
||||
mpv_render_context_free(renderContext);
|
||||
gl.destroy();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return runtimeProbeFailure("shared-memory-create-failed");
|
||||
}
|
||||
const bool sharedMemoryInitialized =
|
||||
runtimeProbeRing.base != nullptr &&
|
||||
runtimeProbeRing.header != nullptr &&
|
||||
runtimeProbeRing.header->magic == FRAME_SHM_MAGIC &&
|
||||
runtimeProbeRing.header->version == FRAME_SHM_VERSION &&
|
||||
runtimeProbeRing.header->width == 16 &&
|
||||
runtimeProbeRing.header->height == 16 &&
|
||||
runtimeProbeRing.header->generation == 1;
|
||||
runtimeProbeRing.destroy();
|
||||
if (!sharedMemoryInitialized) {
|
||||
mpv_render_context_free(renderContext);
|
||||
gl.destroy();
|
||||
mpv_terminate_destroy(mpv);
|
||||
return runtimeProbeFailure("shared-memory-initialize-failed");
|
||||
}
|
||||
|
||||
const std::string libmpvVersion = libmpvClientApiVersion();
|
||||
mpv_render_context_free(renderContext);
|
||||
gl.destroy();
|
||||
mpv_terminate_destroy(mpv);
|
||||
emitLine(JsonWriter()
|
||||
.num("protocol", 1)
|
||||
.boolean("usable", true)
|
||||
.str("libmpv", libmpvVersion)
|
||||
.str("renderApi", gl.renderApiName())
|
||||
.finish());
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
@@ -641,6 +767,9 @@ int main(int argc, char** argv) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
#endif
|
||||
const HelperArgs args = parseArgs(argc, argv);
|
||||
if (args.runtimeProbe) {
|
||||
return runRuntimeProbe();
|
||||
}
|
||||
|
||||
g_state.mpv = mpv_create();
|
||||
if (!g_state.mpv) {
|
||||
|
||||
@@ -36,7 +36,11 @@
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production",
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
@@ -98,7 +102,11 @@
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production",
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
@@ -193,11 +201,27 @@
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"command": "eslint apps/electron-backend/**/*.ts"
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\""
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"inputs": [
|
||||
"default",
|
||||
"^production",
|
||||
"{workspaceRoot}/jest.preset.js",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "apps/electron-backend/jest.config.ts"
|
||||
}
|
||||
|
||||
@@ -526,6 +526,16 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
|
||||
searchEpgPrograms: (searchTerm: string, limit?: number) =>
|
||||
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
|
||||
getEpgMapping: (channelKey: string) =>
|
||||
ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }),
|
||||
getEpgMappingsBatch: (channelKeys: string[]) =>
|
||||
ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }),
|
||||
setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) =>
|
||||
ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }),
|
||||
deleteEpgMapping: (channelKey: string) =>
|
||||
ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }),
|
||||
searchEpgChannels: (searchTerm: string, limit?: number) =>
|
||||
ipcRenderer.invoke('EPG_CHANNEL_SEARCH', { searchTerm, limit }),
|
||||
setMpvPlayerPath: (mpvPlayerPath: string) =>
|
||||
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
|
||||
setVlcPlayerPath: (vlcPlayerPath: string) =>
|
||||
@@ -832,6 +842,10 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DOWNLOADS_START', data),
|
||||
downloadsCancel: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId),
|
||||
downloadsPause: (downloadId: number) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_PAUSE', downloadId),
|
||||
downloadsResume: (downloadId: number, downloadFolder: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder),
|
||||
downloadsRetry: (downloadId: number, downloadFolder: string) =>
|
||||
ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder),
|
||||
downloadsRemove: (downloadId: number) =>
|
||||
|
||||
@@ -133,8 +133,34 @@ describe('Electron app security helpers', () => {
|
||||
|
||||
it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => {
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
|
||||
|
||||
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
|
||||
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, '0'])(
|
||||
'does not probe frame-copy runtime for an inactive %s opt-in',
|
||||
(explicitFrameCopy) => {
|
||||
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
|
||||
if (explicitFrameCopy === undefined) {
|
||||
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
||||
} else {
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY =
|
||||
explicitFrameCopy;
|
||||
}
|
||||
|
||||
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
|
||||
expect(mockIsFrameCopyRuntimeUsable).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('probes frame-copy runtime for an explicit opt-in', () => {
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
|
||||
|
||||
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
|
||||
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => {
|
||||
@@ -215,9 +241,9 @@ describe('Electron app security helpers', () => {
|
||||
expect(mainWindow.loadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('index.html')
|
||||
);
|
||||
expect(
|
||||
mockClearStorageData.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]);
|
||||
expect(mockClearStorageData.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.loadFile.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('continues packaged renderer loading when Electron service worker cleanup fails', async () => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
buildCompoundFtsMatchQuery,
|
||||
buildCompoundLikePatterns,
|
||||
buildM3uPayloadCompoundPatterns,
|
||||
getCompoundResidualTokenGroups,
|
||||
getCompoundSearchWords,
|
||||
getSearchWordPlans,
|
||||
scoreSearchTextMatch,
|
||||
shouldUseContentTitlePrefixIndex,
|
||||
} from './content-search.util';
|
||||
|
||||
describe('content-search.util', () => {
|
||||
describe('getCompoundSearchWords', () => {
|
||||
it('extracts punctuation-joined words from the raw search term', () => {
|
||||
expect(getCompoundSearchWords('A&E')).toEqual(['A&E']);
|
||||
expect(getCompoundSearchWords('US A&E HD')).toEqual(['A&E']);
|
||||
expect(getCompoundSearchWords('Spider-Man')).toEqual([
|
||||
'Spider-Man',
|
||||
]);
|
||||
expect(getCompoundSearchWords("L'Équipe")).toEqual(["L'Équipe"]);
|
||||
});
|
||||
|
||||
it('trims edge punctuation before deciding whether a word is compound', () => {
|
||||
expect(getCompoundSearchWords('(A&E)')).toEqual(['A&E']);
|
||||
expect(getCompoundSearchWords('"A&E:"')).toEqual(['A&E']);
|
||||
});
|
||||
|
||||
it('ignores plain words, standalone punctuation and too-short fragments', () => {
|
||||
expect(getCompoundSearchWords('History')).toEqual([]);
|
||||
expect(getCompoundSearchWords('Tom & Jerry')).toEqual([]);
|
||||
expect(getCompoundSearchWords('a&')).toEqual([]);
|
||||
expect(getCompoundSearchWords('')).toEqual([]);
|
||||
expect(getCompoundSearchWords(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getSearchWordPlans', () => {
|
||||
it('keeps each word with its own token groups and compound flag', () => {
|
||||
expect(getSearchWordPlans('A&E HD')).toEqual([
|
||||
{ compound: 'A&E', tokenGroups: [['a'], ['e']] },
|
||||
{ compound: null, tokenGroups: [['hd']] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips punctuation-only words', () => {
|
||||
expect(getSearchWordPlans('Tom & Jerry')).toEqual([
|
||||
{ compound: null, tokenGroups: [['tom']] },
|
||||
{ compound: null, tokenGroups: [['jerry']] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompoundResidualTokenGroups', () => {
|
||||
it('returns the token groups of the non-compound words only', () => {
|
||||
expect(getCompoundResidualTokenGroups('A&E HD')).toEqual([['hd']]);
|
||||
expect(getCompoundResidualTokenGroups('A&E')).toEqual([]);
|
||||
expect(getCompoundResidualTokenGroups('History')).toEqual([
|
||||
['history'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCompoundFtsMatchQuery', () => {
|
||||
it('quotes each compound word as a trigram substring phrase', () => {
|
||||
expect(buildCompoundFtsMatchQuery('A&E')).toBe('"a&e"');
|
||||
expect(buildCompoundFtsMatchQuery('X-Men')).toBe('"x-men"');
|
||||
});
|
||||
|
||||
it('keeps accented and diacritic-stripped variants', () => {
|
||||
expect(buildCompoundFtsMatchQuery("L'Équipe")).toBe(
|
||||
'("l\'équipe" OR "l\'equipe")'
|
||||
);
|
||||
});
|
||||
|
||||
it('joins multiple compound words with AND', () => {
|
||||
expect(buildCompoundFtsMatchQuery('A&E X-Men')).toBe(
|
||||
'"a&e" AND "x-men"'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty query for terms without compound words', () => {
|
||||
expect(buildCompoundFtsMatchQuery('History Channel')).toBe('');
|
||||
expect(buildCompoundFtsMatchQuery('tv')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCompoundLikePatterns', () => {
|
||||
it('builds case-variant contains patterns around the intact word', () => {
|
||||
const patterns = buildCompoundLikePatterns('A&E');
|
||||
|
||||
expect(patterns).toContain('%a&e%');
|
||||
expect(patterns).toContain('%A&E%');
|
||||
expect(
|
||||
patterns.every(
|
||||
(pattern) =>
|
||||
pattern.startsWith('%') && pattern.endsWith('%')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('escapes LIKE wildcards inside the compound word', () => {
|
||||
expect(buildCompoundLikePatterns('a_b')).toContain('%a\\_b%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildM3uPayloadCompoundPatterns', () => {
|
||||
it('scopes compound contains patterns to payload name/title fields', () => {
|
||||
const patterns = buildM3uPayloadCompoundPatterns('A&E');
|
||||
|
||||
expect(patterns).toContain('%"name":"%a&e%"%');
|
||||
expect(patterns).toContain('%"title":"%a&e%"%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUseContentTitlePrefixIndex', () => {
|
||||
it('still routes compound short-token terms through the prefix index', () => {
|
||||
// The compound FTS lookup supplements the prefix arm instead of
|
||||
// replacing it, so titles starting with "A & E" keep matching.
|
||||
expect(shouldUseContentTitlePrefixIndex('A&E')).toBe(true);
|
||||
expect(shouldUseContentTitlePrefixIndex('Spider-Man')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreSearchTextMatch', () => {
|
||||
it('matches compound words anywhere in the title (issue #1161)', () => {
|
||||
expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40);
|
||||
expect(scoreSearchTextMatch('US | A&E HD', 'A&E HD')).toBe(40);
|
||||
expect(scoreSearchTextMatch('(US) A&E', 'A&E')).toBe(40);
|
||||
});
|
||||
|
||||
it('keeps exact and prefix compound matches ranked above mid-title ones', () => {
|
||||
expect(scoreSearchTextMatch('A&E', 'A&E')).toBe(0);
|
||||
expect(scoreSearchTextMatch('A&E HD', 'A&E')).toBe(10);
|
||||
});
|
||||
|
||||
it('does not match the phrase across word boundaries', () => {
|
||||
expect(scoreSearchTextMatch('Casa e Villa', 'A&E')).toBeNull();
|
||||
expect(scoreSearchTextMatch('Bravo Espana', 'A&E')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires every extra token, not just the compound word', () => {
|
||||
expect(scoreSearchTextMatch('US: A&E', 'A&E HD')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps single short tokens anchored to the title start', () => {
|
||||
expect(scoreSearchTextMatch('TV Sport News', 'tv')).toBe(10);
|
||||
expect(scoreSearchTextMatch('Test TV', 'tv')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Pure text helpers for the content search pipeline (global search,
|
||||
* per-playlist search and M3U payload search). Everything here is
|
||||
* side-effect free and independent of drizzle so it can be unit tested
|
||||
* without database mocks; the SQL condition builders that consume these
|
||||
* helpers live in `content.operations.ts`.
|
||||
*/
|
||||
|
||||
/** Minimum length the trigram FTS index can match (trigram = 3 chars). */
|
||||
const MIN_COMPOUND_WORD_LENGTH = 3;
|
||||
|
||||
const WORD_EDGE_PUNCTUATION_REGEXP = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
|
||||
const INNER_PUNCTUATION_REGEXP = /[^\p{L}\p{N}]/u;
|
||||
|
||||
export function escapeLikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function normalizeSearchMatchText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
function normalizeSqlSearchText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
function getSearchTokens(value: unknown): string[] {
|
||||
const normalized = normalizeSearchMatchText(value);
|
||||
return normalized ? normalized.split(' ') : [];
|
||||
}
|
||||
|
||||
export function getSqlSearchTokenGroups(value: unknown): string[][] {
|
||||
const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean);
|
||||
const normalizedTokens = getSearchTokens(value);
|
||||
const tokenCount = Math.max(rawTokens.length, normalizedTokens.length);
|
||||
|
||||
return Array.from({ length: tokenCount }, (_, index) =>
|
||||
[...new Set([rawTokens[index], normalizedTokens[index]])].filter(
|
||||
Boolean
|
||||
)
|
||||
).filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
export function isShortSearchTokenGroup(tokens: readonly string[]): boolean {
|
||||
return tokens.some((token) => token.length <= 2);
|
||||
}
|
||||
|
||||
function getSqlSearchTokenVariants(value: unknown): string[] {
|
||||
return [...new Set(getSqlSearchTokenGroups(value).flat())];
|
||||
}
|
||||
|
||||
export function buildLikePatterns(
|
||||
term: string,
|
||||
mode: 'contains' | 'prefix' = 'contains'
|
||||
): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of [term, ...getSqlSearchTokenVariants(term)]) {
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleCase =
|
||||
trimmedValue.length > 0
|
||||
? trimmedValue.charAt(0).toLocaleUpperCase() +
|
||||
trimmedValue.slice(1).toLocaleLowerCase()
|
||||
: trimmedValue;
|
||||
|
||||
variants.add(trimmedValue);
|
||||
variants.add(trimmedValue.toLocaleLowerCase());
|
||||
variants.add(trimmedValue.toLocaleUpperCase());
|
||||
variants.add(titleCase);
|
||||
}
|
||||
|
||||
return [...variants].map((value) => {
|
||||
const escapedValue = escapeLikePattern(value);
|
||||
return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGlobPrefixPatterns(token: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of [token, ...getSqlSearchTokenVariants(token)]) {
|
||||
variants.add(value);
|
||||
variants.add(value.toLocaleLowerCase());
|
||||
variants.add(value.toLocaleUpperCase());
|
||||
variants.add(
|
||||
value.charAt(0).toLocaleUpperCase() +
|
||||
value.slice(1).toLocaleLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
return [...variants].map((value) => `${value}*`);
|
||||
}
|
||||
|
||||
export function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean {
|
||||
const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm);
|
||||
|
||||
return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup);
|
||||
}
|
||||
|
||||
export function buildContentTitleFtsMatchQuery(searchTerm: string): string {
|
||||
return getSqlSearchTokenGroups(searchTerm)
|
||||
.map((tokens) => {
|
||||
const quotedTokens = tokens
|
||||
.filter((token) => token.length >= 3)
|
||||
.map((token) => `"${token.replace(/"/g, '""')}"`);
|
||||
|
||||
if (quotedTokens.length <= 1) {
|
||||
return quotedTokens[0] ?? '';
|
||||
}
|
||||
|
||||
return `(${quotedTokens.join(' OR ')})`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
export function shouldUseContentTitleFts(searchTerm: string): boolean {
|
||||
return (
|
||||
!shouldUseContentTitlePrefixIndex(searchTerm) &&
|
||||
buildContentTitleFtsMatchQuery(searchTerm).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace-delimited words from the raw search term that still contain
|
||||
* internal punctuation after trimming their edges — e.g. `a&e`, `x-men`,
|
||||
* `l'équipe`. Tokenization splits these into (often 1-letter) fragments
|
||||
* whose short-token handling anchors the search to the title start, so the
|
||||
* intact word is preserved as an additional exact-substring search unit
|
||||
* (issue #1161: "A&E" not finding "US: A&E").
|
||||
*/
|
||||
export function getCompoundSearchWords(value: unknown): string[] {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const words = new Set<string>();
|
||||
for (const rawWord of value.split(/\s+/)) {
|
||||
const word = rawWord.replace(WORD_EDGE_PUNCTUATION_REGEXP, '');
|
||||
if (
|
||||
word.length < MIN_COMPOUND_WORD_LENGTH ||
|
||||
!INNER_PUNCTUATION_REGEXP.test(word)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
words.add(word);
|
||||
}
|
||||
|
||||
return [...words];
|
||||
}
|
||||
|
||||
/**
|
||||
* One whitespace-delimited search word with its token groups. `compound` is
|
||||
* the intact word when it contains internal punctuation (see
|
||||
* `getCompoundSearchWords`), else null. SQL condition builders compose
|
||||
* per-word conditions from this so a compound word's substring arm stays
|
||||
* AND-constrained by the other words of the query — `A&E HD` must not let
|
||||
* plain `A&E` titles flood the SQL candidate window before scoring runs.
|
||||
*/
|
||||
export interface SearchWordPlan {
|
||||
compound: string | null;
|
||||
tokenGroups: string[][];
|
||||
}
|
||||
|
||||
export function getSearchWordPlans(value: unknown): SearchWordPlan[] {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const plans: SearchWordPlan[] = [];
|
||||
for (const rawWord of value.split(/\s+/)) {
|
||||
const word = rawWord.replace(WORD_EDGE_PUNCTUATION_REGEXP, '');
|
||||
if (!word) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenGroups = getSqlSearchTokenGroups(word);
|
||||
if (tokenGroups.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isCompound =
|
||||
word.length >= MIN_COMPOUND_WORD_LENGTH &&
|
||||
INNER_PUNCTUATION_REGEXP.test(word);
|
||||
plans.push({ compound: isCompound ? word : null, tokenGroups });
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token groups of the non-compound words of the term. When the compound FTS
|
||||
* arm looks up `"a&e"` for `A&E HD`, these groups (`hd`) are re-applied as
|
||||
* LIKE conditions so the supplement cannot fill the candidate limit with
|
||||
* titles that match the compound word alone.
|
||||
*/
|
||||
export function getCompoundResidualTokenGroups(value: unknown): string[][] {
|
||||
return getSearchWordPlans(value)
|
||||
.filter((plan) => plan.compound === null)
|
||||
.flatMap((plan) => plan.tokenGroups);
|
||||
}
|
||||
|
||||
/** As-typed plus diacritic-stripped forms, both long enough for trigram FTS. */
|
||||
function getCompoundWordVariants(word: string): string[] {
|
||||
const stripped = word.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||
|
||||
return [...new Set([word, stripped])].filter(
|
||||
(variant) => variant.length >= MIN_COMPOUND_WORD_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case/diacritic LIKE variants of a compound word as contains patterns
|
||||
* (`%a&e%`). SQLite LIKE is only case-insensitive for ASCII, so the same
|
||||
* lower/upper/title-case expansion as `buildLikePatterns` is applied.
|
||||
*/
|
||||
export function buildCompoundLikePatterns(word: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of getCompoundWordVariants(word)) {
|
||||
variants.add(value);
|
||||
variants.add(value.toLocaleLowerCase());
|
||||
variants.add(value.toLocaleUpperCase());
|
||||
variants.add(
|
||||
value.charAt(0).toLocaleUpperCase() +
|
||||
value.slice(1).toLocaleLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
return [...variants].map((value) => `%${escapeLikePattern(value)}%`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigram FTS match query treating each compound word as a quoted substring
|
||||
* phrase: `"a&e"`, `("café" OR "cafe")`. The trigram tokenizer matches
|
||||
* case-insensitive substrings of >= 3 chars, so this finds the compound word
|
||||
* anywhere in the title without a table scan. Returns '' when the term has
|
||||
* no compound words.
|
||||
*/
|
||||
export function buildCompoundFtsMatchQuery(searchTerm: string): string {
|
||||
return getCompoundSearchWords(searchTerm)
|
||||
.map((word) => {
|
||||
const quotedVariants = [
|
||||
...new Set(
|
||||
getCompoundWordVariants(word).map((variant) =>
|
||||
variant.toLocaleLowerCase()
|
||||
)
|
||||
),
|
||||
].map((variant) => `"${variant.replace(/"/g, '""')}"`);
|
||||
|
||||
if (quotedVariants.length <= 1) {
|
||||
return quotedVariants[0] ?? '';
|
||||
}
|
||||
|
||||
return `(${quotedVariants.join(' OR ')})`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
export function buildM3uPayloadTextFieldPatterns(
|
||||
token: string,
|
||||
mode: 'contains' | 'prefix'
|
||||
): string[] {
|
||||
return buildLikePatterns(token, mode).flatMap((pattern) =>
|
||||
wrapM3uPayloadTextFieldPattern(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
/** Compound-word contains patterns scoped to M3U payload name/title fields. */
|
||||
export function buildM3uPayloadCompoundPatterns(word: string): string[] {
|
||||
return buildCompoundLikePatterns(word).flatMap((pattern) =>
|
||||
wrapM3uPayloadTextFieldPattern(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
function wrapM3uPayloadTextFieldPattern(pattern: string): string[] {
|
||||
return [
|
||||
`%"name":"${pattern}"%`,
|
||||
`%"name": "${pattern}"%`,
|
||||
`%"title":"${pattern}"%`,
|
||||
`%"title": "${pattern}"%`,
|
||||
];
|
||||
}
|
||||
|
||||
export function scoreSearchTextMatch(
|
||||
value: string,
|
||||
searchTerm: string
|
||||
): number | null {
|
||||
const candidateText = normalizeSearchMatchText(value);
|
||||
const searchText = normalizeSearchMatchText(searchTerm);
|
||||
if (!candidateText || !searchText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchTokens = searchText.split(' ');
|
||||
const candidateTokens = candidateText.split(' ');
|
||||
const firstSearchToken = searchTokens[0];
|
||||
|
||||
if (
|
||||
firstSearchToken.length <= 2 &&
|
||||
!candidateText.startsWith(firstSearchToken)
|
||||
) {
|
||||
// Short first tokens stay prefix-anchored to keep one-letter noise
|
||||
// out, but a multi-token phrase ("a e" from "A&E") may still match as
|
||||
// a whole word sequence anywhere in the title ("US: A&E").
|
||||
if (
|
||||
searchTokens.length > 1 &&
|
||||
` ${candidateText} `.includes(` ${searchText} `)
|
||||
) {
|
||||
return 40;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateText === searchText) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
candidateText.startsWith(searchText) &&
|
||||
(searchTokens.length > 1 || firstSearchToken.length <= 2)
|
||||
) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
if (candidateTokens.some((token) => token.startsWith(searchText))) {
|
||||
return 20;
|
||||
}
|
||||
|
||||
if (
|
||||
searchTokens.every((searchToken) =>
|
||||
candidateTokens.some((candidateToken) =>
|
||||
candidateToken.startsWith(searchToken)
|
||||
)
|
||||
)
|
||||
) {
|
||||
return 30;
|
||||
}
|
||||
|
||||
if (candidateText.includes(searchText)) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
if (
|
||||
searchTokens.every((searchToken) => candidateText.includes(searchToken))
|
||||
) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({
|
||||
left,
|
||||
values,
|
||||
}));
|
||||
const orMock = jest.fn((...conditions: unknown[]) => ({
|
||||
kind: 'or',
|
||||
conditions,
|
||||
}));
|
||||
const sqlJoinMock = jest.fn((chunks: unknown[], separator?: unknown) => ({
|
||||
kind: 'sql.join',
|
||||
chunks,
|
||||
@@ -34,7 +38,7 @@ jest.mock('drizzle-orm', () => ({
|
||||
desc: jest.fn(),
|
||||
eq: (left: unknown, right: unknown) => eqMock(left, right),
|
||||
inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values),
|
||||
or: jest.fn(),
|
||||
or: (...conditions: unknown[]) => orMock(...conditions),
|
||||
sql: sqlMock,
|
||||
}));
|
||||
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
getGlobalRecentlyAdded,
|
||||
saveContent,
|
||||
scoreSearchTextMatch,
|
||||
searchContent,
|
||||
} from './content.operations';
|
||||
|
||||
function createDbMock(result: unknown[] = []) {
|
||||
@@ -111,6 +116,7 @@ describe('content.operations', () => {
|
||||
andMock.mockClear();
|
||||
eqMock.mockClear();
|
||||
inArrayMock.mockClear();
|
||||
orMock.mockClear();
|
||||
sqlJoinMock.mockClear();
|
||||
sqlMock.mockClear();
|
||||
});
|
||||
@@ -752,6 +758,299 @@ describe('content.operations', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('finds compound-word channels anywhere in M3U channel names (issue #1161)', () => {
|
||||
const makeChannel = (id: string, name: string) => ({
|
||||
id,
|
||||
url: `https://stream.test/${id}.m3u8`,
|
||||
name,
|
||||
group: { title: 'Entertainment' },
|
||||
tvg: {
|
||||
id,
|
||||
name: '',
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
http: {
|
||||
referrer: '',
|
||||
'user-agent': '',
|
||||
origin: '',
|
||||
},
|
||||
radio: '',
|
||||
});
|
||||
|
||||
const results = buildM3uGlobalSearchResults(
|
||||
[
|
||||
{
|
||||
id: 'm3u-1',
|
||||
name: 'M3U One',
|
||||
payload: JSON.stringify({
|
||||
playlist: {
|
||||
items: [
|
||||
makeChannel('prefixed', 'US: A&E'),
|
||||
makeChannel('exact', 'A&E'),
|
||||
makeChannel('noise', 'Casa e Villa'),
|
||||
makeChannel('other', 'Bravo Espana'),
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
'A&E',
|
||||
false
|
||||
);
|
||||
|
||||
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
|
||||
});
|
||||
|
||||
it('finds compound-word titles anywhere via per-playlist search (issue #1161)', async () => {
|
||||
const makeRow = (id: number, title: string) => ({
|
||||
id,
|
||||
category_id: 10,
|
||||
title,
|
||||
rating: '',
|
||||
added: '',
|
||||
poster_url: '',
|
||||
epg_channel_id: '',
|
||||
tv_archive: 0,
|
||||
tv_archive_duration: 0,
|
||||
direct_source: '',
|
||||
xtream_id: id,
|
||||
type: 'live',
|
||||
});
|
||||
const limit = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
makeRow(1, 'US: A&E'),
|
||||
makeRow(2, 'Casa e Villa'),
|
||||
]);
|
||||
const where = jest.fn().mockReturnValue({ limit });
|
||||
const innerJoin = jest.fn().mockReturnValue({ where });
|
||||
const from = jest.fn().mockReturnValue({ innerJoin });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
const db = {
|
||||
select,
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
const results = await searchContent(db, 'playlist-1', 'A&E', ['live']);
|
||||
|
||||
expect(results.map((item) => item.title)).toEqual(['US: A&E']);
|
||||
|
||||
// The intact compound word must reach SQL as a contains pattern and
|
||||
// be OR-combined with the prefix-anchored token conditions.
|
||||
const patterns = sqlMock.mock.calls
|
||||
.flatMap(([, ...values]) => values)
|
||||
.filter((value): value is string => typeof value === 'string');
|
||||
expect(patterns).toContain('%a&e%');
|
||||
expect(where.mock.calls[0][0].conditions).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: 'or' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('supplements short compound global searches with a trigram FTS substring lookup', async () => {
|
||||
const makeRow = (id: number, title: string) => ({
|
||||
id,
|
||||
category_id: 10,
|
||||
title,
|
||||
rating: '',
|
||||
added: '',
|
||||
poster_url: '',
|
||||
epg_channel_id: '',
|
||||
tv_archive: 0,
|
||||
tv_archive_duration: 0,
|
||||
direct_source: '',
|
||||
xtream_id: id,
|
||||
type: 'live',
|
||||
playlist_id: 'playlist-1',
|
||||
playlist_name: 'Playlist One',
|
||||
});
|
||||
const all = jest
|
||||
.fn()
|
||||
// Prefix-index arm: titles starting with the short first token.
|
||||
.mockResolvedValueOnce([makeRow(1, 'A&E')])
|
||||
// FTS arm: compound substring matches, overlapping with the
|
||||
// prefix arm to prove candidates are deduplicated.
|
||||
.mockResolvedValueOnce([
|
||||
makeRow(2, 'US: A&E'),
|
||||
makeRow(1, 'A&E'),
|
||||
]);
|
||||
const select = jest.fn();
|
||||
const db = {
|
||||
all,
|
||||
select,
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
const results = await globalSearch(
|
||||
db,
|
||||
'A&E',
|
||||
['live'],
|
||||
false,
|
||||
['xtream'],
|
||||
{ limit: 10 }
|
||||
);
|
||||
|
||||
expect(all).toHaveBeenCalledTimes(2);
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
|
||||
const matchSqlCall = sqlMock.mock.calls.find(([strings]) =>
|
||||
Array.from(strings as TemplateStringsArray)
|
||||
.join(' ')
|
||||
.includes('content_title_fts MATCH')
|
||||
);
|
||||
expect(matchSqlCall?.[1]).toBe('"a&e"');
|
||||
|
||||
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
|
||||
});
|
||||
|
||||
it('keeps non-compound tokens as conditions on the compound FTS supplement', async () => {
|
||||
const all = jest.fn().mockResolvedValue([]);
|
||||
const db = {
|
||||
all,
|
||||
select: jest.fn(),
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
await globalSearch(db, 'A&E HD', ['live'], false, ['xtream'], {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(all).toHaveBeenCalledTimes(2);
|
||||
|
||||
const matchSqlCall = sqlMock.mock.calls.find(([strings]) =>
|
||||
Array.from(strings as TemplateStringsArray)
|
||||
.join(' ')
|
||||
.includes('content_title_fts MATCH')
|
||||
);
|
||||
expect(matchSqlCall?.[1]).toBe('"a&e"');
|
||||
|
||||
// The FTS arm must AND-in the residual "hd" token so plain "A&E"
|
||||
// titles cannot exhaust the candidate limit before scoring runs.
|
||||
const residualPatterns = sqlMock.mock.calls
|
||||
.filter(([strings]) =>
|
||||
Array.from(strings as TemplateStringsArray)
|
||||
.join(' ')
|
||||
.includes('c.title LIKE')
|
||||
)
|
||||
.flatMap(([, ...values]) => values)
|
||||
.filter((value): value is string => typeof value === 'string');
|
||||
expect(residualPatterns).toContain('%hd%');
|
||||
});
|
||||
|
||||
it('requires every word of a multi-token compound term in per-playlist SQL conditions', async () => {
|
||||
const limit = jest.fn().mockResolvedValue([]);
|
||||
const where = jest.fn().mockReturnValue({ limit });
|
||||
const innerJoin = jest.fn().mockReturnValue({ where });
|
||||
const from = jest.fn().mockReturnValue({ innerJoin });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
const db = {
|
||||
select,
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
await searchContent(db, 'playlist-1', 'A&E HD', ['live']);
|
||||
|
||||
// One condition per word: the compound "A&E" word (an OR with the
|
||||
// intact-substring arm) plus the plain "hd" word — both AND-ed, so
|
||||
// the compound arm cannot bypass the "hd" requirement.
|
||||
const conditions = where.mock.calls[0][0].conditions as Array<{
|
||||
kind: string;
|
||||
}>;
|
||||
expect(conditions).toHaveLength(4);
|
||||
expect(conditions[2]).toEqual(expect.objectContaining({ kind: 'or' }));
|
||||
expect(conditions[3]).toEqual(expect.objectContaining({ kind: 'and' }));
|
||||
|
||||
const patterns = sqlMock.mock.calls
|
||||
.flatMap(([, ...values]) => values)
|
||||
.filter((value): value is string => typeof value === 'string');
|
||||
expect(patterns).toContain('%a&e%');
|
||||
expect(patterns).toContain('%hd%');
|
||||
});
|
||||
|
||||
it('falls back to a content scan when the compound FTS lookup fails', async () => {
|
||||
const globRow = {
|
||||
id: 1,
|
||||
category_id: 10,
|
||||
title: 'A&E',
|
||||
rating: '',
|
||||
added: '',
|
||||
poster_url: '',
|
||||
epg_channel_id: '',
|
||||
tv_archive: 0,
|
||||
tv_archive_duration: 0,
|
||||
direct_source: '',
|
||||
xtream_id: 1,
|
||||
type: 'live',
|
||||
playlist_id: 'playlist-1',
|
||||
playlist_name: 'Playlist One',
|
||||
};
|
||||
const scanRow = {
|
||||
...globRow,
|
||||
id: 2,
|
||||
xtream_id: 2,
|
||||
title: 'US: A&E',
|
||||
};
|
||||
const all = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([globRow])
|
||||
.mockRejectedValueOnce(new Error('no such table: content_title_fts'));
|
||||
const limit = jest.fn().mockResolvedValue([globRow, scanRow]);
|
||||
const orderBy = jest.fn().mockReturnValue({ limit });
|
||||
const where = jest.fn().mockReturnValue({ orderBy });
|
||||
const innerJoin2 = jest.fn().mockReturnValue({ where });
|
||||
const innerJoin1 = jest.fn().mockReturnValue({ innerJoin: innerJoin2 });
|
||||
const from = jest.fn().mockReturnValue({ innerJoin: innerJoin1 });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
const db = {
|
||||
all,
|
||||
select,
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
const results = await globalSearch(
|
||||
db,
|
||||
'A&E',
|
||||
['live'],
|
||||
false,
|
||||
['xtream'],
|
||||
{ limit: 10 }
|
||||
);
|
||||
|
||||
expect(all).toHaveBeenCalledTimes(2);
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
|
||||
});
|
||||
|
||||
it('adds compound contains patterns to the M3U payload prefilter', async () => {
|
||||
const limit = jest.fn().mockResolvedValue([]);
|
||||
const orderedQuery = {
|
||||
limit,
|
||||
then: (resolve: (value: unknown[]) => void) => resolve([]),
|
||||
};
|
||||
const orderBy = jest.fn().mockReturnValue(orderedQuery);
|
||||
const where = jest.fn().mockReturnValue({ orderBy });
|
||||
const from = jest.fn().mockReturnValue({ where });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
const db = {
|
||||
select,
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
await globalSearch(db, 'A&E', ['live'], false, ['m3u'], {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const searchPatterns = sqlMock.mock.calls
|
||||
.flatMap(([, ...values]) => values)
|
||||
.filter(
|
||||
(value): value is string =>
|
||||
typeof value === 'string' &&
|
||||
value.toLocaleLowerCase().includes('a&e')
|
||||
);
|
||||
|
||||
expect(searchPatterns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('"name":"%a&e%"'),
|
||||
expect.stringContaining('"title":"%a&e%"'),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps accented M3U payload text field variants in SQL prefilters', async () => {
|
||||
const limit = jest.fn().mockResolvedValue([]);
|
||||
const orderedQuery = {
|
||||
|
||||
@@ -14,6 +14,23 @@ import {
|
||||
XtreamGlobalSearchResult,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
buildCompoundFtsMatchQuery,
|
||||
buildCompoundLikePatterns,
|
||||
buildContentTitleFtsMatchQuery,
|
||||
buildGlobPrefixPatterns,
|
||||
buildLikePatterns,
|
||||
buildM3uPayloadCompoundPatterns,
|
||||
buildM3uPayloadTextFieldPatterns,
|
||||
getCompoundResidualTokenGroups,
|
||||
getSearchWordPlans,
|
||||
getSqlSearchTokenGroups,
|
||||
isShortSearchTokenGroup,
|
||||
normalizeSearchMatchText,
|
||||
scoreSearchTextMatch,
|
||||
shouldUseContentTitleFts,
|
||||
shouldUseContentTitlePrefixIndex,
|
||||
} from './content-search.util';
|
||||
import {
|
||||
checkpointOperation,
|
||||
chunkValues,
|
||||
@@ -21,86 +38,7 @@ import {
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
|
||||
function escapeLikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizeSearchMatchText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
function normalizeSqlSearchText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
function getSearchTokens(value: unknown): string[] {
|
||||
const normalized = normalizeSearchMatchText(value);
|
||||
return normalized ? normalized.split(' ') : [];
|
||||
}
|
||||
|
||||
function getSqlSearchTokenGroups(value: unknown): string[][] {
|
||||
const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean);
|
||||
const normalizedTokens = getSearchTokens(value);
|
||||
const tokenCount = Math.max(rawTokens.length, normalizedTokens.length);
|
||||
|
||||
return Array.from({ length: tokenCount }, (_, index) =>
|
||||
[...new Set([rawTokens[index], normalizedTokens[index]])].filter(
|
||||
Boolean
|
||||
)
|
||||
).filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
function isShortSearchTokenGroup(tokens: readonly string[]): boolean {
|
||||
return tokens.some((token) => token.length <= 2);
|
||||
}
|
||||
|
||||
function getSqlSearchTokenVariants(value: unknown): string[] {
|
||||
return [...new Set(getSqlSearchTokenGroups(value).flat())];
|
||||
}
|
||||
|
||||
function buildLikePatterns(
|
||||
term: string,
|
||||
mode: 'contains' | 'prefix' = 'contains'
|
||||
): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of [term, ...getSqlSearchTokenVariants(term)]) {
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleCase =
|
||||
trimmedValue.length > 0
|
||||
? trimmedValue.charAt(0).toLocaleUpperCase() +
|
||||
trimmedValue.slice(1).toLocaleLowerCase()
|
||||
: trimmedValue;
|
||||
|
||||
variants.add(trimmedValue);
|
||||
variants.add(trimmedValue.toLocaleLowerCase());
|
||||
variants.add(trimmedValue.toLocaleUpperCase());
|
||||
variants.add(titleCase);
|
||||
}
|
||||
|
||||
return [...variants].map((value) => {
|
||||
const escapedValue = escapeLikePattern(value);
|
||||
return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`;
|
||||
});
|
||||
}
|
||||
export { scoreSearchTextMatch } from './content-search.util';
|
||||
|
||||
const DEFAULT_GLOBAL_SEARCH_LIMIT = 50;
|
||||
const MAX_GLOBAL_SEARCH_LIMIT = 500;
|
||||
@@ -174,65 +112,6 @@ function normalizeGlobalSearchPagination(
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
export function scoreSearchTextMatch(
|
||||
value: string,
|
||||
searchTerm: string
|
||||
): number | null {
|
||||
const candidateText = normalizeSearchMatchText(value);
|
||||
const searchText = normalizeSearchMatchText(searchTerm);
|
||||
if (!candidateText || !searchText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchTokens = searchText.split(' ');
|
||||
const candidateTokens = candidateText.split(' ');
|
||||
const firstSearchToken = searchTokens[0];
|
||||
|
||||
if (
|
||||
firstSearchToken.length <= 2 &&
|
||||
!candidateText.startsWith(firstSearchToken)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateText === searchText) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
candidateText.startsWith(searchText) &&
|
||||
(searchTokens.length > 1 || firstSearchToken.length <= 2)
|
||||
) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
if (candidateTokens.some((token) => token.startsWith(searchText))) {
|
||||
return 20;
|
||||
}
|
||||
|
||||
if (
|
||||
searchTokens.every((searchToken) =>
|
||||
candidateTokens.some((candidateToken) =>
|
||||
candidateToken.startsWith(searchToken)
|
||||
)
|
||||
)
|
||||
) {
|
||||
return 30;
|
||||
}
|
||||
|
||||
if (candidateText.includes(searchText)) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
if (
|
||||
searchTokens.every((searchToken) => candidateText.includes(searchToken))
|
||||
) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareScoredGlobalSearchResults(
|
||||
first: ScoredGlobalSearchResult,
|
||||
second: ScoredGlobalSearchResult
|
||||
@@ -278,73 +157,55 @@ function getGlobalSearchCandidateLimit(): number {
|
||||
return MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-word title conditions, AND-ed by the caller. A word's condition is the
|
||||
* AND of its token-group LIKE conditions; a punctuation-joined word ("A&E")
|
||||
* additionally matches as an intact contains pattern anywhere in the title
|
||||
* (issue #1161). Composing per word keeps the other words' constraints on
|
||||
* the compound arm, so "A&E HD" cannot fill the SQL candidate limit with
|
||||
* titles that only contain "A&E".
|
||||
*/
|
||||
function buildContentTitleSearchConditions(searchTerm: string) {
|
||||
const tokenGroups = getSqlSearchTokenGroups(searchTerm);
|
||||
if (tokenGroups.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let groupIndex = 0;
|
||||
|
||||
return tokenGroups
|
||||
.map((tokens, index) => {
|
||||
const mode =
|
||||
index === 0 && isShortSearchTokenGroup(tokens)
|
||||
? 'prefix'
|
||||
: 'contains';
|
||||
const likeConditions = tokens.flatMap((token) =>
|
||||
buildLikePatterns(token, mode).map(
|
||||
(pattern) =>
|
||||
sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
)
|
||||
);
|
||||
return or(...likeConditions);
|
||||
})
|
||||
.filter((condition) => condition !== undefined);
|
||||
}
|
||||
return getSearchWordPlans(searchTerm)
|
||||
.map((plan) => {
|
||||
const tokenConditions = plan.tokenGroups
|
||||
.map((tokens) => {
|
||||
const mode =
|
||||
groupIndex === 0 && isShortSearchTokenGroup(tokens)
|
||||
? 'prefix'
|
||||
: 'contains';
|
||||
groupIndex += 1;
|
||||
const likeConditions = tokens.flatMap((token) =>
|
||||
buildLikePatterns(token, mode).map(
|
||||
(pattern) =>
|
||||
sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
)
|
||||
);
|
||||
return or(...likeConditions);
|
||||
})
|
||||
.filter((condition) => condition !== undefined);
|
||||
|
||||
function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean {
|
||||
const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm);
|
||||
|
||||
return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup);
|
||||
}
|
||||
|
||||
function buildContentTitleFtsMatchQuery(searchTerm: string): string {
|
||||
return getSqlSearchTokenGroups(searchTerm)
|
||||
.map((tokens) => {
|
||||
const quotedTokens = tokens
|
||||
.filter((token) => token.length >= 3)
|
||||
.map((token) => `"${token.replace(/"/g, '""')}"`);
|
||||
|
||||
if (quotedTokens.length <= 1) {
|
||||
return quotedTokens[0] ?? '';
|
||||
const tokenArm =
|
||||
tokenConditions.length > 0
|
||||
? and(...tokenConditions)
|
||||
: undefined;
|
||||
if (plan.compound === null) {
|
||||
return tokenArm;
|
||||
}
|
||||
|
||||
return `(${quotedTokens.join(' OR ')})`;
|
||||
const compoundConditions = buildCompoundLikePatterns(
|
||||
plan.compound
|
||||
).map(
|
||||
(pattern) =>
|
||||
sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
return tokenArm === undefined
|
||||
? or(...compoundConditions)
|
||||
: or(tokenArm, ...compoundConditions);
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
function shouldUseContentTitleFts(searchTerm: string): boolean {
|
||||
return (
|
||||
!shouldUseContentTitlePrefixIndex(searchTerm) &&
|
||||
buildContentTitleFtsMatchQuery(searchTerm).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildGlobPrefixPatterns(token: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of [token, ...getSqlSearchTokenVariants(token)]) {
|
||||
variants.add(value);
|
||||
variants.add(value.toLocaleLowerCase());
|
||||
variants.add(value.toLocaleUpperCase());
|
||||
variants.add(
|
||||
value.charAt(0).toLocaleUpperCase() +
|
||||
value.slice(1).toLocaleLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
return [...variants].map((value) => `${value}*`);
|
||||
.filter((condition) => condition !== undefined);
|
||||
}
|
||||
|
||||
function buildRawContentTitleSearchSql(searchTerm: string): SQL[] {
|
||||
@@ -371,6 +232,40 @@ function buildRawContentTitleSearchSql(searchTerm: string): SQL[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains-mode LIKE conditions for the non-compound words of the term,
|
||||
* applied on top of the compound FTS lookup so `A&E HD` only surfaces
|
||||
* candidates that also contain `hd` (the FTS phrase can only express the
|
||||
* compound word — trigram tokens need >= 3 chars).
|
||||
*/
|
||||
function buildCompoundResidualTitleSql(searchTerm: string): SQL[] {
|
||||
return getCompoundResidualTokenGroups(searchTerm).map(
|
||||
(tokens) =>
|
||||
sql`(${sql.join(
|
||||
tokens.flatMap((token) =>
|
||||
buildLikePatterns(token).map(
|
||||
(pattern) => sql`c.title LIKE ${pattern} ESCAPE '\\'`
|
||||
)
|
||||
),
|
||||
sql` OR `
|
||||
)})`
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeXtreamCandidatesById(
|
||||
candidates: XtreamGlobalSearchCandidate[]
|
||||
): XtreamGlobalSearchCandidate[] {
|
||||
const seenIds = new Set<number>();
|
||||
|
||||
return candidates.filter((candidate) => {
|
||||
if (seenIds.has(candidate.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(candidate.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
|
||||
db: AppDatabase,
|
||||
searchTerm: string,
|
||||
@@ -415,12 +310,12 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
|
||||
|
||||
async function selectXtreamGlobalSearchCandidatesWithFts(
|
||||
db: AppDatabase,
|
||||
searchTerm: string,
|
||||
matchQuery: string,
|
||||
types: string[],
|
||||
excludeHidden: boolean,
|
||||
candidateLimit: number
|
||||
candidateLimit: number,
|
||||
residualTitleConditions: SQL[] = []
|
||||
): Promise<XtreamGlobalSearchCandidate[]> {
|
||||
const matchQuery = buildContentTitleFtsMatchQuery(searchTerm);
|
||||
if (!matchQuery || types.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -450,6 +345,11 @@ async function selectXtreamGlobalSearchCandidatesWithFts(
|
||||
types.map((type) => sql`${type}`),
|
||||
sql`, `
|
||||
)})
|
||||
${
|
||||
residualTitleConditions.length > 0
|
||||
? sql`AND ${sql.join(residualTitleConditions, sql` AND `)}`
|
||||
: sql``
|
||||
}
|
||||
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${candidateLimit}
|
||||
@@ -495,38 +395,52 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan(
|
||||
.limit(candidateLimit);
|
||||
}
|
||||
|
||||
function buildM3uPayloadTextFieldPatterns(
|
||||
token: string,
|
||||
mode: 'contains' | 'prefix'
|
||||
): string[] {
|
||||
return buildLikePatterns(token, mode).flatMap((pattern) => [
|
||||
`%"name":"${pattern}"%`,
|
||||
`%"name": "${pattern}"%`,
|
||||
`%"title":"${pattern}"%`,
|
||||
`%"title": "${pattern}"%`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-word M3U payload prefilter conditions, AND-ed by the caller — the same
|
||||
* compound-word composition as `buildContentTitleSearchConditions`: "A&E"
|
||||
* must reach channel names like "US: A&E" (issue #1161), while the other
|
||||
* words of the query keep constraining the candidate playlists.
|
||||
*/
|
||||
function buildM3uPayloadSearchConditions(searchTerm: string) {
|
||||
const tokenGroups = getSqlSearchTokenGroups(searchTerm);
|
||||
if (tokenGroups.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let groupIndex = 0;
|
||||
|
||||
return tokenGroups
|
||||
.map((tokens, index) => {
|
||||
const mode =
|
||||
index === 0 && isShortSearchTokenGroup(tokens)
|
||||
? 'prefix'
|
||||
: 'contains';
|
||||
const patterns = tokens.flatMap((token) =>
|
||||
buildM3uPayloadTextFieldPatterns(token, mode)
|
||||
);
|
||||
const likeConditions = patterns.map(
|
||||
return getSearchWordPlans(searchTerm)
|
||||
.map((plan) => {
|
||||
const tokenConditions = plan.tokenGroups
|
||||
.map((tokens) => {
|
||||
const mode =
|
||||
groupIndex === 0 && isShortSearchTokenGroup(tokens)
|
||||
? 'prefix'
|
||||
: 'contains';
|
||||
groupIndex += 1;
|
||||
const patterns = tokens.flatMap((token) =>
|
||||
buildM3uPayloadTextFieldPatterns(token, mode)
|
||||
);
|
||||
const likeConditions = patterns.map(
|
||||
(pattern) =>
|
||||
sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
return or(...likeConditions);
|
||||
})
|
||||
.filter((condition) => condition !== undefined);
|
||||
|
||||
const tokenArm =
|
||||
tokenConditions.length > 0
|
||||
? and(...tokenConditions)
|
||||
: undefined;
|
||||
if (plan.compound === null) {
|
||||
return tokenArm;
|
||||
}
|
||||
|
||||
const compoundConditions = buildM3uPayloadCompoundPatterns(
|
||||
plan.compound
|
||||
).map(
|
||||
(pattern) =>
|
||||
sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'`
|
||||
);
|
||||
return or(...likeConditions);
|
||||
return tokenArm === undefined
|
||||
? or(...compoundConditions)
|
||||
: or(tokenArm, ...compoundConditions);
|
||||
})
|
||||
.filter((condition) => condition !== undefined);
|
||||
}
|
||||
@@ -1207,11 +1121,45 @@ export async function globalSearch(
|
||||
excludeHidden,
|
||||
candidateLimit
|
||||
);
|
||||
|
||||
// The prefix arm only sees titles that start with the short first
|
||||
// token ("A&E" -> GLOB 'a*'), so a compound word like "a&e" is
|
||||
// additionally looked up as a trigram FTS substring to reach
|
||||
// titles such as "US: A&E" (issue #1161). The non-compound words
|
||||
// of the query stay applied as LIKE conditions so this arm cannot
|
||||
// fill the candidate limit with compound-only matches.
|
||||
const compoundMatchQuery = buildCompoundFtsMatchQuery(searchTerm);
|
||||
if (compoundMatchQuery) {
|
||||
try {
|
||||
const compoundCandidates =
|
||||
await selectXtreamGlobalSearchCandidatesWithFts(
|
||||
db,
|
||||
compoundMatchQuery,
|
||||
types,
|
||||
excludeHidden,
|
||||
candidateLimit,
|
||||
buildCompoundResidualTitleSql(searchTerm)
|
||||
);
|
||||
candidates = dedupeXtreamCandidatesById([
|
||||
...candidates,
|
||||
...compoundCandidates,
|
||||
]);
|
||||
} catch {
|
||||
candidates =
|
||||
await selectXtreamGlobalSearchCandidatesWithContentScan(
|
||||
db,
|
||||
searchTerm,
|
||||
types,
|
||||
excludeHidden,
|
||||
candidateLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (shouldUseContentTitleFts(searchTerm)) {
|
||||
try {
|
||||
candidates = await selectXtreamGlobalSearchCandidatesWithFts(
|
||||
db,
|
||||
searchTerm,
|
||||
buildContentTitleFtsMatchQuery(searchTerm),
|
||||
types,
|
||||
excludeHidden,
|
||||
candidateLimit
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
const eqMock = jest.fn((left: unknown, right: unknown) => ({
|
||||
kind: 'eq',
|
||||
left,
|
||||
right,
|
||||
}));
|
||||
const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({
|
||||
kind: 'inArray',
|
||||
left,
|
||||
values,
|
||||
}));
|
||||
const orMock = jest.fn((...conditions: unknown[]) => ({
|
||||
kind: 'or',
|
||||
conditions,
|
||||
}));
|
||||
const sqlMock = jest.fn(
|
||||
(strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
kind: 'sql',
|
||||
strings: Array.from(strings),
|
||||
values,
|
||||
})
|
||||
);
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
and: jest.fn(),
|
||||
eq: (left: unknown, right: unknown) => eqMock(left, right),
|
||||
inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values),
|
||||
or: (...conditions: unknown[]) => orMock(...conditions),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
|
||||
sqlMock(strings, ...values),
|
||||
}));
|
||||
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
deleteEpgMapping,
|
||||
getEpgMapping,
|
||||
getEpgMappingsBatch,
|
||||
searchEpgChannels,
|
||||
setEpgMapping,
|
||||
} from './epg-mapping.operations';
|
||||
|
||||
function createSelectChain(result: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
chain['from'] = jest.fn().mockReturnValue(chain);
|
||||
chain['where'] = jest.fn().mockReturnValue(chain);
|
||||
chain['orderBy'] = jest.fn().mockReturnValue(chain);
|
||||
chain['limit'] = jest.fn().mockResolvedValue(result);
|
||||
chain['then'] = (
|
||||
resolve: (value: unknown) => unknown,
|
||||
reject: (reason: unknown) => unknown
|
||||
) => Promise.resolve(result).then(resolve, reject);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function createDbMock(selectResult: unknown[] = []) {
|
||||
const chain = createSelectChain(selectResult);
|
||||
const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined);
|
||||
const values = jest.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
const insert = jest.fn().mockReturnValue({ values });
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere });
|
||||
const select = jest.fn().mockReturnValue(chain);
|
||||
|
||||
return {
|
||||
db: {
|
||||
select,
|
||||
insert,
|
||||
delete: deleteFn,
|
||||
} as unknown as AppDatabase,
|
||||
chain,
|
||||
insert,
|
||||
values,
|
||||
onConflictDoUpdate,
|
||||
deleteFn,
|
||||
deleteWhere,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
describe('epg-mapping.operations', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getEpgMapping', () => {
|
||||
it('returns the mapping row when present', async () => {
|
||||
const row = {
|
||||
id: 1,
|
||||
channelKey: 'xtream:pl-1:42',
|
||||
epgChannelId: 'bbc.one.uk',
|
||||
playlistId: 'pl-1',
|
||||
};
|
||||
const { db } = createDbMock([row]);
|
||||
|
||||
await expect(getEpgMapping(db, 'xtream:pl-1:42')).resolves.toEqual(
|
||||
row
|
||||
);
|
||||
expect(eqMock).toHaveBeenCalledWith(
|
||||
schema.epgChannelMappings.channelKey,
|
||||
'xtream:pl-1:42'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no mapping exists', async () => {
|
||||
const { db } = createDbMock([]);
|
||||
|
||||
await expect(getEpgMapping(db, 'missing')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEpgMappingsBatch', () => {
|
||||
it('returns an empty map without querying for an empty input', async () => {
|
||||
const { db, select } = createDbMock();
|
||||
|
||||
const result = await getEpgMappingsBatch(db, []);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps channel keys to their EPG channel ids', async () => {
|
||||
const { db } = createDbMock([
|
||||
{ channelKey: 'a', epgChannelId: 'epg-a' },
|
||||
{ channelKey: 'b', epgChannelId: 'epg-b' },
|
||||
]);
|
||||
|
||||
const result = await getEpgMappingsBatch(db, ['a', 'b', 'c']);
|
||||
|
||||
expect(result.get('a')).toBe('epg-a');
|
||||
expect(result.get('b')).toBe('epg-b');
|
||||
expect(result.has('c')).toBe(false);
|
||||
expect(inArrayMock).toHaveBeenCalledWith(
|
||||
schema.epgChannelMappings.channelKey,
|
||||
['a', 'b', 'c']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEpgMapping', () => {
|
||||
it('upserts on the channel key', async () => {
|
||||
const { db, insert, values, onConflictDoUpdate } = createDbMock();
|
||||
|
||||
await expect(
|
||||
setEpgMapping(db, 'xtream:pl-1:42', 'bbc.one.uk', 'pl-1')
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(schema.epgChannelMappings);
|
||||
expect(values).toHaveBeenCalledWith({
|
||||
channelKey: 'xtream:pl-1:42',
|
||||
epgChannelId: 'bbc.one.uk',
|
||||
playlistId: 'pl-1',
|
||||
});
|
||||
expect(onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: schema.epgChannelMappings.channelKey,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('stores null when no playlist id is provided', async () => {
|
||||
const { db, values } = createDbMock();
|
||||
|
||||
await setEpgMapping(db, 'bbc.one', 'bbc.one.uk');
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ playlistId: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEpgMapping', () => {
|
||||
it('deletes by channel key', async () => {
|
||||
const { db, deleteFn, deleteWhere } = createDbMock();
|
||||
|
||||
await expect(deleteEpgMapping(db, 'bbc.one')).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(deleteFn).toHaveBeenCalledWith(schema.epgChannelMappings);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(eqMock).toHaveBeenCalledWith(
|
||||
schema.epgChannelMappings.channelKey,
|
||||
'bbc.one'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEpgChannels', () => {
|
||||
it('escapes LIKE wildcards and searches name and id with an ESCAPE clause', async () => {
|
||||
const { db } = createDbMock([]);
|
||||
|
||||
await searchEpgChannels(db, 'HBO%_');
|
||||
|
||||
// sql`${column} LIKE ${pattern} ESCAPE '\\'` — the pattern is the
|
||||
// second interpolated value.
|
||||
const patterns = sqlMock.mock.calls.map((call) => call[2]);
|
||||
expect(patterns).toContain('%HBO\\%\\_%');
|
||||
const templates = sqlMock.mock.calls.map((call) =>
|
||||
Array.from(call[0] as TemplateStringsArray).join('?')
|
||||
);
|
||||
expect(
|
||||
templates.every((template) => template.includes("ESCAPE '\\'"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('escapes backslashes so a trailing "\\" cannot corrupt the pattern', async () => {
|
||||
const { db } = createDbMock([]);
|
||||
|
||||
await searchEpgChannels(db, 'C\\');
|
||||
|
||||
const patterns = sqlMock.mock.calls.map((call) => call[2]);
|
||||
expect(patterns).toContain('%C\\\\%');
|
||||
});
|
||||
|
||||
it('applies the result limit', async () => {
|
||||
const { db, chain } = createDbMock([]);
|
||||
|
||||
await searchEpgChannels(db, 'news', 5);
|
||||
|
||||
expect(chain['limit']).toHaveBeenCalledWith(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { and, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
/**
|
||||
* Get a single EPG channel mapping by lookup key.
|
||||
*/
|
||||
export async function getEpgMapping(
|
||||
db: AppDatabase,
|
||||
channelKey: string
|
||||
): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.epgChannelMappings.id,
|
||||
channelKey: schema.epgChannelMappings.channelKey,
|
||||
epgChannelId: schema.epgChannelMappings.epgChannelId,
|
||||
playlistId: schema.epgChannelMappings.playlistId,
|
||||
})
|
||||
.from(schema.epgChannelMappings)
|
||||
.where(eq(schema.epgChannelMappings.channelKey, channelKey))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch lookup of EPG channel mappings for multiple keys.
|
||||
* Returns a Map of channelKey → epgChannelId (only for keys that have mappings).
|
||||
*/
|
||||
export async function getEpgMappingsBatch(
|
||||
db: AppDatabase,
|
||||
channelKeys: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
if (channelKeys.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
channelKey: schema.epgChannelMappings.channelKey,
|
||||
epgChannelId: schema.epgChannelMappings.epgChannelId,
|
||||
})
|
||||
.from(schema.epgChannelMappings)
|
||||
.where(inArray(schema.epgChannelMappings.channelKey, channelKeys));
|
||||
|
||||
const mapping = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
mapping.set(row.channelKey, row.epgChannelId);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an EPG channel mapping (upsert).
|
||||
*/
|
||||
export async function setEpgMapping(
|
||||
db: AppDatabase,
|
||||
channelKey: string,
|
||||
epgChannelId: string,
|
||||
playlistId?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.insert(schema.epgChannelMappings)
|
||||
.values({
|
||||
channelKey,
|
||||
epgChannelId,
|
||||
playlistId: playlistId ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.epgChannelMappings.channelKey,
|
||||
set: {
|
||||
epgChannelId,
|
||||
playlistId: playlistId ?? null,
|
||||
updatedAt: sql`(datetime('now'))`,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an EPG channel mapping.
|
||||
*/
|
||||
export async function deleteEpgMapping(
|
||||
db: AppDatabase,
|
||||
channelKey: string
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.delete(schema.epgChannelMappings)
|
||||
.where(eq(schema.epgChannelMappings.channelKey, channelKey));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search EPG channels by display name for the mapping dialog.
|
||||
*/
|
||||
export async function searchEpgChannels(
|
||||
db: AppDatabase,
|
||||
searchTerm: string,
|
||||
limit = 50
|
||||
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
|
||||
// Escape the escape character itself and the LIKE wildcards so user
|
||||
// input like "HBO%", "_BC" or a trailing "\" is matched literally —
|
||||
// an unescaped backslash would pair with the following character (or
|
||||
// the closing "%") under the ESCAPE clause and corrupt the pattern.
|
||||
const escaped = searchTerm.trim().replace(/[\\%_]/g, '\\$&');
|
||||
const pattern = `%${escaped}%`;
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: schema.epgChannels.id,
|
||||
displayName: schema.epgChannels.displayName,
|
||||
iconUrl: schema.epgChannels.iconUrl,
|
||||
})
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
or(
|
||||
sql`${schema.epgChannels.displayName} LIKE ${pattern} ESCAPE '\\'`,
|
||||
sql`${schema.epgChannels.id} LIKE ${pattern} ESCAPE '\\'`
|
||||
)
|
||||
)
|
||||
.orderBy(schema.epgChannels.displayName)
|
||||
.limit(limit);
|
||||
}
|
||||
@@ -25,9 +25,12 @@ jest.mock('drizzle-orm', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { createDbMock } from './operations.test-helpers';
|
||||
import {
|
||||
getAllGlobalFavorites,
|
||||
getFavorites,
|
||||
getGlobalFavorites,
|
||||
reorderGlobalFavorites,
|
||||
} from './favorites.operations';
|
||||
@@ -93,6 +96,38 @@ describe('favorites.operations', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression for issue #1138: archive metadata must survive every
|
||||
// favorites projection so catch-up stays available outside Live TV.
|
||||
it('selects archive metadata for playlist favorites', async () => {
|
||||
const { db, select } = createGlobalFavoritesDbMock([]);
|
||||
|
||||
await getFavorites(db, 'playlist-1');
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('selects archive metadata for global favorites', async () => {
|
||||
const { db, select } = createGlobalFavoritesDbMock([]);
|
||||
|
||||
await getGlobalFavorites(db);
|
||||
await getAllGlobalFavorites(db);
|
||||
|
||||
expect(select).toHaveBeenCalledTimes(2);
|
||||
for (const [projection] of select.mock.calls) {
|
||||
expect(projection).toEqual(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('reorderGlobalFavorites', () => {
|
||||
it('short-circuits without touching the db when there are no updates', async () => {
|
||||
const { db, update, transaction } = createDbMock();
|
||||
|
||||
@@ -73,6 +73,8 @@ export async function getFavorites(db: AppDatabase, playlistId: string) {
|
||||
poster_url: schema.content.posterUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
added_at: schema.favorites.addedAt,
|
||||
position: schema.favorites.position,
|
||||
})
|
||||
@@ -117,6 +119,8 @@ function selectGlobalFavoriteRows(
|
||||
: {}),
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
playlist_id: schema.playlists.id,
|
||||
playlist_name: schema.playlists.name,
|
||||
added_at: schema.favorites.addedAt,
|
||||
|
||||
+16
-2
@@ -35,7 +35,7 @@ describe('recently-viewed.operations', () => {
|
||||
{ id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' },
|
||||
{ id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' },
|
||||
];
|
||||
const { db, queries } = createDbMock([rows]);
|
||||
const { db, queries, select } = createDbMock([rows]);
|
||||
|
||||
await expect(getRecentlyViewed(db)).resolves.toEqual(rows);
|
||||
|
||||
@@ -47,11 +47,19 @@ describe('recently-viewed.operations', () => {
|
||||
);
|
||||
expect(queries[0].limit).toHaveBeenCalledWith(100);
|
||||
expect(queries[0].innerJoin).toHaveBeenCalledTimes(3);
|
||||
// Regression for issue #1138: archive metadata must survive the
|
||||
// recent projections so catch-up stays available outside Live TV.
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => {
|
||||
const rows = [{ id: 5, title: 'Recent Movie' }];
|
||||
const { db, queries } = createDbMock([rows]);
|
||||
const { db, queries, select } = createDbMock([rows]);
|
||||
|
||||
await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual(
|
||||
rows
|
||||
@@ -65,6 +73,12 @@ describe('recently-viewed.operations', () => {
|
||||
schema.recentlyViewed.viewedAt
|
||||
);
|
||||
expect(queries[0].limit).toHaveBeenCalledWith(100);
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export async function getRecentlyViewed(db: AppDatabase) {
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
playlist_id: schema.categories.playlistId,
|
||||
playlist_name: schema.playlists.name,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
@@ -58,6 +60,8 @@ export async function getRecentItems(
|
||||
backdrop_url: schema.content.backdropUrl,
|
||||
xtream_id: schema.content.xtreamId,
|
||||
type: schema.content.type,
|
||||
tv_archive: schema.content.tvArchive,
|
||||
tv_archive_duration: schema.content.tvArchiveDuration,
|
||||
viewed_at: schema.recentlyViewed.viewedAt,
|
||||
})
|
||||
.from(schema.recentlyViewed)
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
recentlyViewed,
|
||||
favorites,
|
||||
epgChannels,
|
||||
epgChannelMappings,
|
||||
epgPrograms,
|
||||
playbackPositions,
|
||||
downloads,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
export function setMainWindow(win: BrowserWindow): void {
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
export function broadcastDownloadUpdate(): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
findAvailableFinalPath,
|
||||
getPartialDownloadPath,
|
||||
getPartialDownloadSize,
|
||||
removePartialDownload,
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile,
|
||||
reserveAvailableDownloadFile,
|
||||
} from './download-file-path';
|
||||
|
||||
@@ -51,6 +58,45 @@ describe('reserveAvailableDownloadFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserveAvailablePartialDownloadFile', () => {
|
||||
it('reserves a .part path while keeping the final path free', () => {
|
||||
const reserveFile = jest.fn();
|
||||
|
||||
expect(
|
||||
reserveAvailablePartialDownloadFile(
|
||||
'/downloads',
|
||||
'movie.mp4',
|
||||
reserveFile,
|
||||
() => false
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'movie.mp4',
|
||||
partialPath: join('/downloads', 'movie.mp4.part'),
|
||||
path: join('/downloads', 'movie.mp4'),
|
||||
});
|
||||
expect(reserveFile).toHaveBeenCalledWith(
|
||||
join('/downloads', 'movie.mp4.part')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips candidates when the final file already exists', () => {
|
||||
const reserveFile = jest.fn();
|
||||
|
||||
expect(
|
||||
reserveAvailablePartialDownloadFile(
|
||||
'/downloads',
|
||||
'movie.mp4',
|
||||
reserveFile,
|
||||
(filePath) => filePath.endsWith('movie.mp4')
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'movie (1).mp4',
|
||||
partialPath: join('/downloads', 'movie (1).mp4.part'),
|
||||
path: join('/downloads', 'movie (1).mp4'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePartialDownload', () => {
|
||||
it('removes only the actual partial save path', () => {
|
||||
const requestedPath = join('/downloads', 'movie.mp4');
|
||||
@@ -88,3 +134,73 @@ describe('removePartialDownload', () => {
|
||||
expect(removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial download helpers', () => {
|
||||
it('derives, removes, and sizes .part files from the final path', () => {
|
||||
const finalPath = join('/downloads', 'movie.mp4');
|
||||
const partialPath = join('/downloads', 'movie.mp4.part');
|
||||
const removeFile = jest.fn();
|
||||
|
||||
expect(getPartialDownloadPath(finalPath)).toBe(partialPath);
|
||||
expect(
|
||||
removePartialDownloadFile(
|
||||
finalPath,
|
||||
(filePath) => filePath === partialPath,
|
||||
removeFile
|
||||
)
|
||||
).toBe(true);
|
||||
expect(removeFile).toHaveBeenCalledWith(partialPath);
|
||||
expect(
|
||||
getPartialDownloadSize(finalPath, (filePath) => {
|
||||
expect(filePath).toBe(partialPath);
|
||||
return { size: 128 };
|
||||
})
|
||||
).toBe(128);
|
||||
});
|
||||
|
||||
it('refuses to size a .part that is not a regular file', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-'));
|
||||
const finalPath = join(directory, 'movie.mp4');
|
||||
const partialPath = `${finalPath}.part`;
|
||||
|
||||
try {
|
||||
writeFileSync(join(directory, 'target.bin'), 'attacker');
|
||||
symlinkSync(join(directory, 'target.bin'), partialPath);
|
||||
|
||||
expect(() => getPartialDownloadSize(finalPath)).toThrow(
|
||||
'not a regular file'
|
||||
);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('finds the next numbered destination whose final and partial paths are free', () => {
|
||||
const occupied = new Set([
|
||||
join('/downloads', 'movie.mp4'),
|
||||
join('/downloads', 'movie (1).mp4'),
|
||||
join('/downloads', 'movie (2).mp4.part'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findAvailableFinalPath(join('/downloads', 'movie.mp4'), (path) =>
|
||||
occupied.has(path)
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'movie (3).mp4',
|
||||
path: join('/downloads', 'movie (3).mp4'),
|
||||
});
|
||||
});
|
||||
|
||||
it('reports zero for a missing .part with the default stat reader', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-'));
|
||||
|
||||
try {
|
||||
expect(
|
||||
getPartialDownloadSize(join(directory, 'missing.mp4'))
|
||||
).toBe(0);
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,61 @@
|
||||
import { closeSync, existsSync, openSync, unlinkSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
|
||||
export interface ReservedDownloadFile {
|
||||
filename: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ReservedPartialDownloadFile extends ReservedDownloadFile {
|
||||
partialPath: string;
|
||||
}
|
||||
|
||||
function createExclusiveFile(filePath: string): void {
|
||||
const descriptor = openSync(filePath, 'wx');
|
||||
closeSync(descriptor);
|
||||
}
|
||||
|
||||
function createExistsError(filePath: string): NodeJS.ErrnoException {
|
||||
const error = new Error(`File already exists: ${filePath}`) as NodeJS.ErrnoException;
|
||||
error.code = 'EEXIST';
|
||||
return error;
|
||||
}
|
||||
|
||||
export function getPartialDownloadPath(filePath: string): string {
|
||||
return `${filePath}.part`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next numbered destination whose final and .part paths are both free. Used
|
||||
* when a retained download's recorded destination got occupied while it was
|
||||
* paused or failed — the occupying file is never inspected or deleted.
|
||||
*/
|
||||
export function findAvailableFinalPath(
|
||||
filePath: string,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): { filename: string; path: string } {
|
||||
const directory = dirname(filePath);
|
||||
const extension = extname(filePath);
|
||||
const stem = basename(filePath, extension);
|
||||
|
||||
for (let suffix = 1; ; suffix++) {
|
||||
const filename = `${stem} (${suffix})${extension}`;
|
||||
const candidate = join(directory, filename);
|
||||
if (
|
||||
!pathExists(candidate) &&
|
||||
!pathExists(getPartialDownloadPath(candidate))
|
||||
) {
|
||||
return { filename, path: candidate };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveAvailableDownloadFile(
|
||||
directory: string,
|
||||
requestedFilename: string,
|
||||
@@ -38,6 +83,38 @@ export function reserveAvailableDownloadFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveAvailablePartialDownloadFile(
|
||||
directory: string,
|
||||
requestedFilename: string,
|
||||
reserveFile: (filePath: string) => void = createExclusiveFile,
|
||||
pathExists: (filePath: string) => boolean = existsSync
|
||||
): ReservedPartialDownloadFile {
|
||||
const extension = extname(requestedFilename);
|
||||
const stem = extension
|
||||
? requestedFilename.slice(0, -extension.length)
|
||||
: requestedFilename;
|
||||
let candidate = requestedFilename;
|
||||
let suffix = 1;
|
||||
|
||||
for (;;) {
|
||||
const filePath = join(directory, candidate);
|
||||
const partialPath = getPartialDownloadPath(filePath);
|
||||
try {
|
||||
if (pathExists(filePath)) {
|
||||
throw createExistsError(filePath);
|
||||
}
|
||||
reserveFile(partialPath);
|
||||
return { filename: candidate, partialPath, path: filePath };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
candidate = `${stem} (${suffix})${extension}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadSavePathItem {
|
||||
getSavePath(): string;
|
||||
}
|
||||
@@ -55,3 +132,49 @@ export function removePartialDownload(
|
||||
removeFile(savePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removePartialDownloadFile(
|
||||
filePath: string | null | undefined,
|
||||
pathExists: (filePath: string) => boolean = existsSync,
|
||||
removeFile: (filePath: string) => void = unlinkSync
|
||||
): boolean {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const partialPath = getPartialDownloadPath(filePath);
|
||||
if (!pathExists(partialPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
removeFile(partialPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getRegularFileStats(filePath: string): { size: number } {
|
||||
// lstat + isFile so appending to a retained .part can never follow a
|
||||
// symlink planted in its place while the download was paused.
|
||||
const stats = lstatSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Partial download is not a regular file: ${filePath}`);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export function getPartialDownloadSize(
|
||||
filePath: string | null | undefined,
|
||||
getStats: (filePath: string) => { size: number } = getRegularFileStats
|
||||
): number {
|
||||
if (!filePath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return getStats(getPartialDownloadPath(filePath)).size;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import {
|
||||
createTask,
|
||||
waitForCallCount,
|
||||
waitForStatus,
|
||||
} from './download.test-helpers';
|
||||
|
||||
describe('download finalization', () => {
|
||||
it('falls back to copying the completed partial when hard links are unsupported', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
}) as never
|
||||
);
|
||||
const link = jest.fn(async () => {
|
||||
const error = new Error('not supported') as NodeJS.ErrnoException;
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
});
|
||||
const copyFile = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile,
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
expect(link).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(copyFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4',
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes when copied output exists but partial cleanup fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
}) as never
|
||||
);
|
||||
const link = jest.fn(async () => {
|
||||
const error = new Error('not supported') as NodeJS.ErrnoException;
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
});
|
||||
const copyFile = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => {
|
||||
const error = new Error('cleanup denied') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
});
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile,
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
expect(copyFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4',
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
errorMessage: null,
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a completed partial when finalization fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
}) as never
|
||||
);
|
||||
const link = jest.fn(async () => {
|
||||
const error = new Error('not supported') as NodeJS.ErrnoException;
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
});
|
||||
const copyFile = jest.fn(async () => {
|
||||
const error = new Error('disk full') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOSPC';
|
||||
throw error;
|
||||
});
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile,
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
await waitForStatus(set, 'failed');
|
||||
|
||||
expect(copyFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4',
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
errorMessage: 'disk full',
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'failed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { copyFile, link, stat, unlink } from 'node:fs/promises';
|
||||
import * as schema from '../../database/schema';
|
||||
import {
|
||||
getPartialDownloadPath,
|
||||
getPartialDownloadSize,
|
||||
removePartialDownloadFile,
|
||||
type ReservedPartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
import type {
|
||||
CompletedPartialProgress,
|
||||
DownloadsDatabase,
|
||||
DownloadTask,
|
||||
TransferProgress,
|
||||
} from './download-task';
|
||||
import { describeError, TruncatedTransferError } from './download-transfer';
|
||||
|
||||
/**
|
||||
* Persistence for a failed startDownload() attempt, after its cancel/pause
|
||||
* checkpoints have been ruled out. Chooses between: retaining a truncated
|
||||
* transfer for a Range retry, committing an already-finalized file,
|
||||
* retaining a completed partial, or the generic delete-partial failure.
|
||||
*/
|
||||
export async function handleDownloadFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile | undefined,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
if (error instanceof TruncatedTransferError && reservation) {
|
||||
// The short response is retained so a retry can continue the
|
||||
// transfer via Range instead of starting over.
|
||||
await persistCompletedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
{
|
||||
bytesDownloaded: error.progress.bytesDownloaded,
|
||||
filePath: reservation.path,
|
||||
totalBytes: error.progress.totalBytes,
|
||||
},
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCompletedFileProgress =
|
||||
await getExistingCompletedFileProgress(task);
|
||||
if (existingCompletedFileProgress) {
|
||||
removePartialFile(existingCompletedFileProgress.filePath);
|
||||
await persistCompletion(
|
||||
db,
|
||||
task,
|
||||
task.fileName,
|
||||
existingCompletedFileProgress.filePath,
|
||||
existingCompletedFileProgress.bytesDownloaded,
|
||||
existingCompletedFileProgress.totalBytes
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const completedPartialProgress = getCompletedPartialProgress(task);
|
||||
if (completedPartialProgress) {
|
||||
await persistCompletedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
completedPartialProgress,
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[Downloads] Error downloading ${task.fileName}:`,
|
||||
describeError(error)
|
||||
);
|
||||
removePartialFile(task.filePath);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: describeError(error),
|
||||
filePath: null,
|
||||
resumeValidator: null,
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
}
|
||||
|
||||
export async function completeDownloadFromPartial(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
progress: TransferProgress
|
||||
): Promise<void> {
|
||||
let fileSize: number;
|
||||
try {
|
||||
fileSize = await finalizePartialDownload(
|
||||
reservation,
|
||||
progress.bytesDownloaded
|
||||
);
|
||||
} catch (error) {
|
||||
if (task.cancelRequested || task.pauseRequested) {
|
||||
throw error;
|
||||
}
|
||||
await persistFinalizationFailure(
|
||||
db,
|
||||
task,
|
||||
reservation,
|
||||
progress,
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await persistCompletion(
|
||||
db,
|
||||
task,
|
||||
reservation.filename,
|
||||
reservation.path,
|
||||
fileSize,
|
||||
progress.totalBytes
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistCompletion(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
totalBytes: number | null
|
||||
): Promise<void> {
|
||||
console.log(`[Downloads] Completed: ${fileName}`);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: fileSize,
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath,
|
||||
resumeValidator: null,
|
||||
status: 'completed',
|
||||
totalBytes: totalBytes ?? fileSize,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
}
|
||||
|
||||
async function persistFinalizationFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
progress: TransferProgress,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
await persistRetainedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
reservation.filename,
|
||||
reservation.path,
|
||||
progress,
|
||||
error,
|
||||
`[Downloads] Error finalizing ${reservation.filename}:`
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistCompletedPartialFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
progress: CompletedPartialProgress,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
await persistRetainedPartialFailure(
|
||||
db,
|
||||
task,
|
||||
task.fileName,
|
||||
progress.filePath,
|
||||
progress,
|
||||
error,
|
||||
`[Downloads] Error downloading ${task.fileName}:`
|
||||
);
|
||||
}
|
||||
|
||||
async function persistRetainedPartialFailure(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
progress: TransferProgress,
|
||||
error: unknown,
|
||||
logMessage: string
|
||||
): Promise<void> {
|
||||
console.error(logMessage, describeError(error));
|
||||
const totalBytes = progress.totalBytes ?? progress.bytesDownloaded;
|
||||
task.totalBytes = totalBytes;
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.bytesDownloaded,
|
||||
errorMessage: describeError(error),
|
||||
fileName,
|
||||
filePath,
|
||||
status: 'failed',
|
||||
totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
}
|
||||
|
||||
export async function getExistingCompletedFileProgress(
|
||||
task: DownloadTask
|
||||
): Promise<CompletedPartialProgress | null> {
|
||||
if (
|
||||
!task.filePath ||
|
||||
task.totalBytes === null ||
|
||||
task.totalBytes === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStats = await stat(task.filePath);
|
||||
if (fileStats.size !== task.totalBytes) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bytesDownloaded: fileStats.size,
|
||||
filePath: task.filePath,
|
||||
totalBytes: task.totalBytes,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[Downloads] Failed to inspect target file:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompletedPartialProgress(
|
||||
task: DownloadTask
|
||||
): CompletedPartialProgress | null {
|
||||
if (
|
||||
!task.filePath ||
|
||||
task.totalBytes === null ||
|
||||
task.totalBytes === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existsSync(getPartialDownloadPath(task.filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytesDownloaded = getPartialDownloadSize(task.filePath);
|
||||
if (bytesDownloaded !== task.totalBytes) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bytesDownloaded,
|
||||
filePath: task.filePath,
|
||||
totalBytes: task.totalBytes,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to inspect partial file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPausedByteCount(task: DownloadTask): number {
|
||||
try {
|
||||
return getPartialDownloadSize(task.filePath);
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to inspect partial file:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizePartialDownload(
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
expectedFileSize: number
|
||||
): Promise<number> {
|
||||
try {
|
||||
await link(reservation.partialPath, reservation.path);
|
||||
} catch (error) {
|
||||
if (!canCopyCompletedPartialAfterLinkFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
await copyFile(
|
||||
reservation.partialPath,
|
||||
reservation.path,
|
||||
constants.COPYFILE_EXCL
|
||||
);
|
||||
}
|
||||
try {
|
||||
await unlink(reservation.partialPath);
|
||||
} catch (error) {
|
||||
const fileSize = await getExpectedFinalFileSize(
|
||||
reservation.path,
|
||||
expectedFileSize
|
||||
);
|
||||
if (fileSize !== null) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete completed partial file:',
|
||||
error
|
||||
);
|
||||
return fileSize;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const fileStats = await stat(reservation.path);
|
||||
return fileStats.size;
|
||||
}
|
||||
|
||||
async function getExpectedFinalFileSize(
|
||||
filePath: string,
|
||||
expectedFileSize: number
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
return fileStats.size === expectedFileSize ? fileStats.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
|
||||
const errorCode = (error as NodeJS.ErrnoException).code;
|
||||
return (
|
||||
errorCode === 'EACCES' ||
|
||||
errorCode === 'ENOSYS' ||
|
||||
errorCode === 'ENOTSUP' ||
|
||||
errorCode === 'EOPNOTSUPP' ||
|
||||
errorCode === 'EPERM' ||
|
||||
errorCode === 'EXDEV'
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns false when a .part exists but could not be deleted. */
|
||||
export function removePartialFile(filePath: string | null | undefined): boolean {
|
||||
try {
|
||||
removePartialDownloadFile(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to delete partial file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,397 @@
|
||||
import { cleanupStaleDownloadFiles } from './stale-download-files';
|
||||
type StatSyncStub = (path: string) => { isFile: () => boolean; size: number };
|
||||
let statSyncOverride: StatSyncStub | null = null;
|
||||
jest.mock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
statSync: (path: string) =>
|
||||
statSyncOverride
|
||||
? statSyncOverride(path)
|
||||
: jest.requireActual('node:fs').statSync(path),
|
||||
}));
|
||||
|
||||
describe('cleanupStaleDownloadFiles', () => {
|
||||
it('removes every persisted partial path and ignores empty paths', () => {
|
||||
const removeFile = jest.fn();
|
||||
describe('resetStaleDownloads', () => {
|
||||
it('keeps interrupted partial downloads as paused and pauses queued rows', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
cleanupStaleDownloadFiles(
|
||||
[
|
||||
{ filePath: '/downloads/one.mp4' },
|
||||
{ filePath: null },
|
||||
{ filePath: '/downloads/two.mp4' },
|
||||
],
|
||||
removeFile
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 1,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
{
|
||||
filePath: null,
|
||||
id: 2,
|
||||
status: 'queued',
|
||||
totalBytes: null,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 64),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 64,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
|
||||
expect(removeFile).toHaveBeenCalledTimes(2);
|
||||
expect(removeFile).toHaveBeenNthCalledWith(1, '/downloads/one.mp4');
|
||||
expect(removeFile).toHaveBeenNthCalledWith(2, '/downloads/two.mp4');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
expect(set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'failed' })
|
||||
);
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues cleaning other stale files after one removal fails', () => {
|
||||
const removeFile = jest.fn((filePath: string) => {
|
||||
if (filePath.endsWith('one.mp4')) {
|
||||
throw new Error('locked');
|
||||
}
|
||||
it('commits a finalized download whose completion was interrupted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
// Crash happened after finalizePartialDownload() (final file on disk,
|
||||
// .part gone) but before persistCompletion() flipped the row.
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 9,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
statSyncOverride = () => ({ isFile: () => true, size: 100 });
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
try {
|
||||
const recovery = await import('./download-recovery');
|
||||
await recovery.resetStaleDownloads();
|
||||
} finally {
|
||||
statSyncOverride = null;
|
||||
}
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 100,
|
||||
errorMessage: null,
|
||||
status: 'completed',
|
||||
})
|
||||
);
|
||||
// Any leftover .part from the interrupted commit is cleaned up, and
|
||||
// the final file's path is never cleared or deleted.
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'failed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the retained partial of a resumed download that was still queued', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/resumed.mp4',
|
||||
id: 7,
|
||||
status: 'queued',
|
||||
totalBytes: 1000,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 900),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 900,
|
||||
errorMessage: null,
|
||||
status: 'paused',
|
||||
totalBytes: 1000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('removes non-recoverable interrupted partial files before clearing the persisted path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/empty.mp4',
|
||||
id: 1,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/empty.mp4'
|
||||
);
|
||||
expect(removePartialDownloadFile.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
set.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the persisted path when non-recoverable partial cleanup fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const staleDownloads = [
|
||||
{
|
||||
filePath: '/downloads/locked.mp4',
|
||||
id: 1,
|
||||
status: 'downloading',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(staleDownloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const deleteError = new Error('permission denied');
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw deleteError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
cleanupStaleDownloadFiles(
|
||||
[
|
||||
{ filePath: '/downloads/one.mp4' },
|
||||
{ filePath: '/downloads/two.mp4' },
|
||||
],
|
||||
removeFile
|
||||
);
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
expect(removeFile).toHaveBeenCalledTimes(2);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Downloads] Failed to delete stale partial file:',
|
||||
'/downloads/one.mp4',
|
||||
expect.any(Error)
|
||||
);
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
consoleError.mockRestore();
|
||||
try {
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/locked.mp4'
|
||||
);
|
||||
const failedUpdate = (set.mock.calls as unknown[][]).find(
|
||||
([value]) =>
|
||||
(value as { status?: string } | undefined)?.status ===
|
||||
'failed'
|
||||
)?.[0] as Record<string, unknown> | undefined;
|
||||
expect(failedUpdate).toEqual(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
expect(failedUpdate).not.toHaveProperty('filePath');
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Downloads] Failed to delete interrupted partial file:',
|
||||
'/downloads/locked.mp4',
|
||||
deleteError
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('removes leftover partial files for completed downloads without changing their status', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(downloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps completed downloads completed when leftover partial cleanup still fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 1,
|
||||
status: 'completed',
|
||||
totalBytes: 100,
|
||||
},
|
||||
];
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(downloads),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const cleanupError = new Error('locked');
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw cleanupError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadSize: jest.fn(),
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
|
||||
const { resetStaleDownloads } = await import('./download-recovery');
|
||||
|
||||
try {
|
||||
await resetStaleDownloads();
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Downloads] Failed to delete completed partial file:',
|
||||
'/downloads/movie.mp4',
|
||||
cleanupError
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,215 @@
|
||||
import { inArray, sql } from 'drizzle-orm';
|
||||
import { statSync } from 'node:fs';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { cleanupStaleDownloadFiles } from './stale-download-files';
|
||||
import {
|
||||
getPartialDownloadSize,
|
||||
removePartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
|
||||
interface StaleDownload {
|
||||
filePath: string | null;
|
||||
id: number;
|
||||
status: string;
|
||||
totalBytes: number | null;
|
||||
}
|
||||
|
||||
function getRecoverablePartialSize(download: StaleDownload): number {
|
||||
if (
|
||||
(download.status !== 'downloading' && download.status !== 'queued') ||
|
||||
!download.filePath
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return getPartialDownloadSize(download.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to inspect interrupted partial file:',
|
||||
error
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A crash between finalizePartialDownload() and persistCompletion() leaves a
|
||||
* 'downloading' row whose .part is gone but whose final file is fully on
|
||||
* disk. Recognize that file (size must match the recorded total) so recovery
|
||||
* commits the completion instead of orphaning the file and re-downloading.
|
||||
*/
|
||||
function getFinalizedFileSize(download: StaleDownload): number | null {
|
||||
if (
|
||||
download.status !== 'downloading' ||
|
||||
!download.filePath ||
|
||||
download.totalBytes === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = statSync(download.filePath);
|
||||
return stats.isFile() && stats.size === download.totalBytes
|
||||
? stats.size
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeFailedPartial(download: StaleDownload): boolean {
|
||||
if (!download.filePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
removePartialDownloadFile(download.filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete interrupted partial file:',
|
||||
download.filePath,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeCompletedPartial(download: StaleDownload): void {
|
||||
if (!download.filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
removePartialDownloadFile(download.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete completed partial file:',
|
||||
download.filePath,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetStaleDownloads(): Promise<void> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const staleDownloads = await db
|
||||
const downloads = await db
|
||||
.select({
|
||||
filePath: schema.downloads.filePath,
|
||||
id: schema.downloads.id,
|
||||
status: schema.downloads.status,
|
||||
totalBytes: schema.downloads.totalBytes,
|
||||
})
|
||||
.from(schema.downloads)
|
||||
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
|
||||
const ownedReservations = staleDownloads.filter(
|
||||
(item) => item.filePath
|
||||
.where(
|
||||
inArray(schema.downloads.status, [
|
||||
'queued',
|
||||
'downloading',
|
||||
'completed',
|
||||
])
|
||||
);
|
||||
const completedDownloads = downloads.filter(
|
||||
(download) => download.status === 'completed'
|
||||
);
|
||||
const staleDownloads = downloads.filter(
|
||||
(download) => download.status !== 'completed'
|
||||
);
|
||||
const finalizedDownloads = staleDownloads
|
||||
.map((download) => ({
|
||||
...download,
|
||||
finalizedSize: getFinalizedFileSize(download),
|
||||
}))
|
||||
.filter((download) => download.finalizedSize !== null);
|
||||
const finalizedIds = new Set(
|
||||
finalizedDownloads.map((download) => download.id)
|
||||
);
|
||||
// Queued rows are recoverable even without partial bytes: a resumed
|
||||
// download waiting behind an active one is persisted as 'queued' with
|
||||
// its retained .part, and a never-started queued row loses nothing by
|
||||
// becoming 'paused' instead of 'failed'.
|
||||
const recoverableDownloads = staleDownloads
|
||||
.filter((download) => !finalizedIds.has(download.id))
|
||||
.map((download) => ({
|
||||
...download,
|
||||
bytesDownloaded: getRecoverablePartialSize(download),
|
||||
}))
|
||||
.filter(
|
||||
(download) =>
|
||||
download.status === 'queued' || download.bytesDownloaded > 0
|
||||
);
|
||||
const recoverableIds = new Set(
|
||||
recoverableDownloads.map((download) => download.id)
|
||||
);
|
||||
const failedDownloads = staleDownloads.filter(
|
||||
(download) =>
|
||||
!recoverableIds.has(download.id) &&
|
||||
!finalizedIds.has(download.id)
|
||||
);
|
||||
const cleanupResult = failedDownloads.map((download) => ({
|
||||
...download,
|
||||
partialRemoved: removeFailedPartial(download),
|
||||
}));
|
||||
const failedIdsWithRemovedPartials = cleanupResult
|
||||
.filter((download) => download.partialRemoved)
|
||||
.map((download) => download.id);
|
||||
const failedIdsWithRetainedPartials = cleanupResult
|
||||
.filter((download) => !download.partialRemoved)
|
||||
.map((download) => download.id);
|
||||
|
||||
cleanupStaleDownloadFiles(ownedReservations);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
|
||||
completedDownloads.forEach(removeCompletedPartial);
|
||||
|
||||
if (ownedReservations.length > 0) {
|
||||
for (const download of finalizedDownloads) {
|
||||
// The interrupted commit may also have left the .part behind.
|
||||
removeCompletedPartial(download);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({ filePath: null })
|
||||
.where(
|
||||
inArray(
|
||||
schema.downloads.id,
|
||||
ownedReservations.map((item) => item.id)
|
||||
)
|
||||
);
|
||||
.set({
|
||||
bytesDownloaded: download.finalizedSize,
|
||||
errorMessage: null,
|
||||
status: 'completed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, [download.id]));
|
||||
}
|
||||
|
||||
for (const download of recoverableDownloads) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: download.bytesDownloaded,
|
||||
errorMessage: null,
|
||||
status: 'paused',
|
||||
totalBytes: download.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, [download.id]));
|
||||
}
|
||||
|
||||
if (failedIdsWithRemovedPartials.length > 0) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, failedIdsWithRemovedPartials));
|
||||
}
|
||||
|
||||
if (failedIdsWithRetainedPartials.length > 0) {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'Download interrupted by application restart',
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(inArray(schema.downloads.id, failedIdsWithRetainedPartials));
|
||||
}
|
||||
|
||||
console.log('[Downloads] Reset stale downloads');
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error resetting stale downloads:', error);
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
|
||||
describe('download requests resume', () => {
|
||||
it('enqueues a paused download with stored headers and original target path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }),
|
||||
resumeValidator: '"etag-9"',
|
||||
status: 'paused',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue([row]);
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { resumeDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
resumeDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
// DB-recorded retained paths stay usable after folder switches.
|
||||
expect(authorizer.requireAuthorized).not.toHaveBeenCalled();
|
||||
expect(enqueueDownload).toHaveBeenCalledWith({
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
headers: { 'User-Agent': 'IPTVnator' },
|
||||
id: 42,
|
||||
resumeValidator: '"etag-9"',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enqueue when a concurrent resume already claimed the row', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
requestHeaders: null,
|
||||
resumeValidator: null,
|
||||
status: 'paused',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue([row]);
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({
|
||||
// The conditional status='paused' claim matched no rows.
|
||||
where: jest.fn().mockResolvedValue({ changes: 0 }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { resumeDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
resumeDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
expect(enqueueDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a failed download with a retained partial at the original target path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }),
|
||||
resumeValidator: '"etag-9"',
|
||||
status: 'failed',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue([row]);
|
||||
const set = jest.fn<
|
||||
{ where: jest.Mock },
|
||||
[Record<string, unknown>]
|
||||
>(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { retryDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
retryDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
const update = set.mock.calls[0][0];
|
||||
expect(authorizer.requireAuthorized).not.toHaveBeenCalled();
|
||||
expect(update).toEqual(
|
||||
expect.objectContaining({
|
||||
errorMessage: null,
|
||||
fileName: 'movie.mp4',
|
||||
status: 'queued',
|
||||
})
|
||||
);
|
||||
expect(update).not.toHaveProperty('bytesDownloaded');
|
||||
expect(update).not.toHaveProperty('filePath');
|
||||
expect(update).not.toHaveProperty('totalBytes');
|
||||
expect(enqueueDownload).toHaveBeenCalledWith({
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
headers: { 'User-Agent': 'IPTVnator' },
|
||||
id: 42,
|
||||
resumeValidator: '"etag-9"',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes the retained partial before re-downloading a failed row from scratch', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const failedRow = {
|
||||
contentType: 'vod',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
playlistId: 'playlist-1',
|
||||
status: 'failed',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
};
|
||||
const limit = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 'playlist-1' }])
|
||||
.mockResolvedValueOnce([failedRow]);
|
||||
const set = jest.fn<
|
||||
{ where: jest.Mock },
|
||||
[Record<string, unknown>]
|
||||
>(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const { startDownloadRequest } = await import('./download-requests');
|
||||
|
||||
await expect(
|
||||
startDownloadRequest(
|
||||
{
|
||||
contentType: 'vod',
|
||||
downloadFolder: '/downloads',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
},
|
||||
authorizer
|
||||
)
|
||||
).resolves.toEqual({ id: 42, success: true });
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: null,
|
||||
resumeValidator: null,
|
||||
status: 'queued',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the re-download when the retained partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const failedRow = {
|
||||
contentType: 'vod',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
playlistId: 'playlist-1',
|
||||
status: 'failed',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
};
|
||||
const limit = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 'playlist-1' }])
|
||||
.mockResolvedValueOnce([failedRow]);
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
});
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
enqueueDownload,
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const { startDownloadRequest } = await import(
|
||||
'./download-requests'
|
||||
);
|
||||
|
||||
await expect(
|
||||
startDownloadRequest(
|
||||
{
|
||||
contentType: 'vod',
|
||||
downloadFolder: '/downloads',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Movie',
|
||||
url: 'https://example.test/movie.mp4',
|
||||
xtreamId: 7,
|
||||
},
|
||||
authorizer
|
||||
)
|
||||
).resolves.toEqual({
|
||||
error: 'Could not delete the previous partial file',
|
||||
id: 42,
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
|
||||
// The row keeps its filePath ownership and nothing is enqueued.
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
expect(enqueueDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { extname } from 'node:path';
|
||||
import { basename, dirname, extname } from 'node:path';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { assertRemoteUrlAllowed } from '../url-safety';
|
||||
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import { enqueueDownload } from './download-runtime';
|
||||
|
||||
export interface StartDownloadRequest {
|
||||
@@ -25,18 +26,21 @@ export interface StartDownloadRequest {
|
||||
macAddress?: string;
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
}
|
||||
|
||||
function getExtensionFromUrl(url: string): string {
|
||||
try {
|
||||
return extname(new URL(url).pathname) || '.mp4';
|
||||
// Sanitize too: URL pathnames may legally contain characters like ':'
|
||||
// that would create NTFS alternate data streams on Windows.
|
||||
const extension = sanitizeFilename(extname(new URL(url).pathname));
|
||||
return extension.startsWith('.') ? extension : '.mp4';
|
||||
} catch {
|
||||
return '.mp4';
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
}
|
||||
|
||||
function createFileName(title: string, url: string): string {
|
||||
return sanitizeFilename(title) + getExtensionFromUrl(url);
|
||||
}
|
||||
@@ -44,13 +48,62 @@ function createFileName(title: string, url: string): string {
|
||||
function createHeaders(
|
||||
headers: StartDownloadRequest['headers']
|
||||
): Record<string, string> | undefined {
|
||||
return headers
|
||||
? {
|
||||
'User-Agent': headers.userAgent || '',
|
||||
Origin: headers.origin || '',
|
||||
Referer: headers.referer || '',
|
||||
}
|
||||
: undefined;
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
if (headers.userAgent) {
|
||||
result['User-Agent'] = headers.userAgent;
|
||||
}
|
||||
if (headers.origin) {
|
||||
result.Origin = headers.origin;
|
||||
}
|
||||
if (headers.referer) {
|
||||
result.Referer = headers.referer;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function serializeHeaders(
|
||||
headers: Record<string, string> | undefined
|
||||
): string | null {
|
||||
return headers ? JSON.stringify(headers) : null;
|
||||
}
|
||||
|
||||
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
|
||||
|
||||
function parseStoredHeaders(
|
||||
value: string | null
|
||||
): Record<string, string> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Re-apply the write-time allowlist so a tampered or imported
|
||||
// database row cannot smuggle arbitrary headers into requests.
|
||||
const entries = parsed as Record<string, unknown>;
|
||||
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
|
||||
(acc, key) => {
|
||||
const headerValue = entries[key];
|
||||
if (typeof headerValue === 'string') {
|
||||
acc[key] = headerValue;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDownloadRequest(
|
||||
@@ -98,6 +151,7 @@ export async function startDownloadRequest(
|
||||
)
|
||||
.limit(1);
|
||||
const fileName = createFileName(data.title, data.url);
|
||||
const headers = createHeaders(data.headers);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const item = existing[0];
|
||||
@@ -109,6 +163,26 @@ export async function startDownloadRequest(
|
||||
};
|
||||
}
|
||||
|
||||
if (item.status === 'failed' && item.filePath) {
|
||||
// A failed row can still reference a retained .part; delete it
|
||||
// before the restart clears filePath, or the file is orphaned.
|
||||
// A locked .part must keep its database owner, so fail the
|
||||
// restart instead of proceeding without the cleanup.
|
||||
try {
|
||||
removePartialDownloadFile(item.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete retained partial before re-download:',
|
||||
error
|
||||
);
|
||||
return {
|
||||
error: 'Could not delete the previous partial file',
|
||||
id: item.id,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
@@ -116,6 +190,8 @@ export async function startDownloadRequest(
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath: null,
|
||||
requestHeaders: serializeHeaders(headers),
|
||||
resumeValidator: null,
|
||||
status: 'queued',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
@@ -125,7 +201,7 @@ export async function startDownloadRequest(
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
headers: createHeaders(data.headers),
|
||||
headers,
|
||||
id: item.id,
|
||||
url: data.url,
|
||||
});
|
||||
@@ -138,6 +214,7 @@ export async function startDownloadRequest(
|
||||
fileName,
|
||||
playlistId: data.playlistId,
|
||||
posterUrl: data.posterUrl,
|
||||
requestHeaders: serializeHeaders(headers),
|
||||
seasonNumber: data.seasonNumber,
|
||||
seriesXtreamId: data.seriesXtreamId,
|
||||
status: 'queued',
|
||||
@@ -149,7 +226,7 @@ export async function startDownloadRequest(
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
headers: createHeaders(data.headers),
|
||||
headers,
|
||||
id: insertedId,
|
||||
url: data.url,
|
||||
});
|
||||
@@ -162,7 +239,6 @@ export async function retryDownloadRequest(
|
||||
authorizer: DownloadDirectoryAuthorizer
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Downloads] Retry download:', downloadId);
|
||||
const directory = await authorizer.requireAuthorized(downloadFolder);
|
||||
const db = await getDatabase();
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -183,24 +259,127 @@ export async function retryDownloadRequest(
|
||||
};
|
||||
}
|
||||
|
||||
const fileName = createFileName(item.title, item.url);
|
||||
const retainedFilePath =
|
||||
item.status === 'failed' && item.filePath ? item.filePath : null;
|
||||
// A retained filePath was written by the main process after its folder
|
||||
// was authorized; requiring the folder to still be the CURRENT selection
|
||||
// would strand the retry after the user switches download folders.
|
||||
const directory = retainedFilePath
|
||||
? dirname(retainedFilePath)
|
||||
: await authorizer.requireAuthorized(downloadFolder);
|
||||
const fileName = retainedFilePath
|
||||
? basename(retainedFilePath)
|
||||
: createFileName(item.title, item.url);
|
||||
const queuedUpdate = retainedFilePath
|
||||
? {
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
status: 'queued' as const,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
}
|
||||
: {
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath: null,
|
||||
resumeValidator: null,
|
||||
status: 'queued' as const,
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
filePath: null,
|
||||
status: 'queued',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.set(queuedUpdate)
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
filePath: retainedFilePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
id: item.id,
|
||||
resumeValidator: retainedFilePath ? item.resumeValidator : null,
|
||||
totalBytes: retainedFilePath ? item.totalBytes : null,
|
||||
url: item.url,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function resumeDownloadRequest(
|
||||
downloadId: number,
|
||||
downloadFolder: string,
|
||||
authorizer: DownloadDirectoryAuthorizer
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
console.log('[Downloads] Resume download:', downloadId);
|
||||
const db = await getDatabase();
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return { error: 'Download not found', success: false };
|
||||
}
|
||||
|
||||
const item = existing[0];
|
||||
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
|
||||
if (item.status !== 'paused') {
|
||||
return {
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// See retryDownloadRequest: DB-recorded retained paths stay usable after
|
||||
// the user switches download folders.
|
||||
const directory = item.filePath
|
||||
? dirname(item.filePath)
|
||||
: await authorizer.requireAuthorized(downloadFolder);
|
||||
const fileName = item.filePath
|
||||
? basename(item.filePath)
|
||||
: createFileName(item.title, item.url);
|
||||
|
||||
// Claim the row atomically: a concurrent resume for the same id loses
|
||||
// this conditional update and must not enqueue a second task.
|
||||
const claim = await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
fileName,
|
||||
status: 'queued',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.downloads.id, downloadId),
|
||||
eq(schema.downloads.status, 'paused')
|
||||
)
|
||||
);
|
||||
if (hasNoChanges(claim)) {
|
||||
return {
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
enqueueDownload({
|
||||
directory,
|
||||
fileName,
|
||||
filePath: item.filePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
id: item.id,
|
||||
resumeValidator: item.resumeValidator,
|
||||
totalBytes: item.totalBytes,
|
||||
url: item.url,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function hasNoChanges(result: unknown): boolean {
|
||||
return (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'changes' in result &&
|
||||
(result as { changes: number }).changes === 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import { createTask, waitForStatus } from './download.test-helpers';
|
||||
|
||||
describe('destination collision handling', () => {
|
||||
it('redirects a retained partial to a numbered destination instead of unlinking', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const occupied = new Set([
|
||||
'/downloads/movie.mp4',
|
||||
'/downloads/movie.mp4.part',
|
||||
]);
|
||||
const renameMock = jest.fn(async () => undefined);
|
||||
const unlinkMock = jest.fn(async () => undefined);
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.alloc(50, 'r')]),
|
||||
headers: { 'content-range': 'bytes 50-99/100' },
|
||||
status: 206,
|
||||
}) as never
|
||||
);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn((path: string) => occupied.has(path)),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link: jest.fn(async () => undefined),
|
||||
rename: renameMock,
|
||||
stat: jest.fn(async () => ({ size: 100 })),
|
||||
unlink: unlinkMock,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
findAvailableFinalPath: jest.fn(() => ({
|
||||
filename: 'movie (1).mp4',
|
||||
path: '/downloads/movie (1).mp4',
|
||||
})),
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 50),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 100,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
// The foreign file at the recorded destination is never touched.
|
||||
expect(unlinkMock).not.toHaveBeenCalledWith('/downloads/movie.mp4');
|
||||
expect(renameMock).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie (1).mp4.part'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'movie (1).mp4',
|
||||
filePath: '/downloads/movie (1).mp4',
|
||||
})
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/downloads/movie (1).mp4',
|
||||
status: 'completed',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue deduplication', () => {
|
||||
it('ignores a second enqueue for an id that is already queued', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects: jest.fn(
|
||||
() => new Promise(() => undefined)
|
||||
),
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
// Occupy the active slot, then enqueue id 43 twice (double resume).
|
||||
runtime.enqueueDownload(createTask());
|
||||
runtime.enqueueDownload({ ...createTask(), id: 43 });
|
||||
runtime.enqueueDownload({ ...createTask(), id: 43 });
|
||||
|
||||
// Only one queue entry exists: the first cancel finds it, the second
|
||||
// finds nothing (and no paused DB row backs id 43 in this harness).
|
||||
const dbSelect = jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
(db as { select?: jest.Mock }).select = dbSelect;
|
||||
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import type { DownloadTask } from './download-task';
|
||||
|
||||
interface ResumeHarness {
|
||||
createWriteStream: jest.Mock;
|
||||
removePartialDownloadFile: jest.Mock;
|
||||
requestWithValidatedRedirects: jest.Mock;
|
||||
set: jest.Mock;
|
||||
runtime: typeof import('./download-runtime');
|
||||
}
|
||||
|
||||
interface ResumeHarnessOptions {
|
||||
partialSize: number;
|
||||
response: {
|
||||
data: Readable;
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
};
|
||||
/** 'enoent' makes stat() report a missing target file. */
|
||||
finalSize: number | 'enoent';
|
||||
}
|
||||
|
||||
function createTask(overrides: Partial<DownloadTask> = {}): DownloadTask {
|
||||
return {
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
id: 42,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForStatus(set: jest.Mock, status: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (set.mock.calls.some(([value]) => value?.status === status)) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ status }));
|
||||
}
|
||||
|
||||
async function setupResumeHarness(
|
||||
options: ResumeHarnessOptions
|
||||
): Promise<ResumeHarness> {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () => options.response as never
|
||||
);
|
||||
const createWriteStream = jest.fn(() => new PassThrough());
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream,
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => {
|
||||
if (options.finalSize === 'enoent') {
|
||||
const error = new Error('missing') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return { size: options.finalSize };
|
||||
}),
|
||||
unlink: jest.fn(async () => undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => options.partialSize),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
return {
|
||||
createWriteStream,
|
||||
removePartialDownloadFile,
|
||||
requestWithValidatedRedirects,
|
||||
set,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
describe('download resume validation', () => {
|
||||
it('sends the stored validator as If-Range alongside the Range header', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 54,
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('rest')]),
|
||||
headers: { 'content-range': 'bytes 50-53/54' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.requestWithValidatedRedirects).toHaveBeenCalledWith(
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'If-Range': '"etag-1"',
|
||||
Range: 'bytes=50-',
|
||||
}),
|
||||
}),
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
expect(harness.createWriteStream).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
{ flags: 'a' }
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts from byte zero when a resume request is answered with 200', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4', etag: '"etag-2"' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const consoleWarn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.createWriteStream).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
{ flags: 'w' }
|
||||
);
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
resumeValidator: '"etag-2"',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
expect(harness.set).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'failed' })
|
||||
);
|
||||
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleWarn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails the transfer when the 206 response starts at the wrong offset', async () => {
|
||||
const body = new PassThrough();
|
||||
body.write('rest');
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: body,
|
||||
headers: { 'content-range': 'bytes 0-53/54' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'Server returned an invalid resume range',
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
expect(body.destroyed).toBe(true);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains the partial when a 206 ends before the advertised size', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 50,
|
||||
response: {
|
||||
// Only 20 of the 50 remaining bytes arrive before EOF.
|
||||
data: Readable.from([Buffer.alloc(20, 'r')]),
|
||||
headers: { 'content-range': 'bytes 50-99/100' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 100,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 70,
|
||||
errorMessage: 'Transfer ended before the advertised size',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('captures a strong ETag from the first response for later resumes', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 0,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4', etag: '"etag-3"' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(createTask());
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resumeValidator: '"etag-3"' })
|
||||
);
|
||||
});
|
||||
|
||||
it('pauses before reservation without a network request or file path', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 0,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
pauseRequested: true,
|
||||
});
|
||||
await waitForStatus(harness.set, 'paused');
|
||||
|
||||
expect(harness.requestWithValidatedRedirects).not.toHaveBeenCalled();
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
filePath: null,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to Last-Modified when the ETag is weak', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 0,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: {
|
||||
'content-length': '4',
|
||||
etag: 'W/"weak-etag"',
|
||||
'last-modified': 'Wed, 01 Jul 2026 10:00:00 GMT',
|
||||
},
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(createTask());
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resumeValidator: 'Wed, 01 Jul 2026 10:00:00 GMT',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createTask, waitForStatus } from './download.test-helpers';
|
||||
|
||||
describe('retained completed partials', () => {
|
||||
it('finalizes a retained completed partial without another HTTP request', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn();
|
||||
const link = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn((filePath: string) =>
|
||||
filePath.endsWith('.part')
|
||||
),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 4),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 4,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
|
||||
expect(link).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'completed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('finalizes a retained completed partial beside an occupied destination without touching it', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn();
|
||||
const link = jest.fn(async () => undefined);
|
||||
const unlink = jest.fn(async () => undefined);
|
||||
const occupied = new Set([
|
||||
'/downloads/movie.mp4',
|
||||
'/downloads/movie.mp4.part',
|
||||
]);
|
||||
const rename = jest.fn(async (from: string, to: string) => {
|
||||
occupied.delete(from);
|
||||
occupied.add(to);
|
||||
});
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn((filePath: string) => occupied.has(filePath)),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link,
|
||||
rename,
|
||||
stat: jest.fn(async () => ({ size: 4 })),
|
||||
unlink,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
findAvailableFinalPath: jest.fn(() => ({
|
||||
filename: 'movie (1).mp4',
|
||||
path: '/downloads/movie (1).mp4',
|
||||
})),
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 4),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 4,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
// The occupied destination is never unlinked or overwritten; the
|
||||
// retained partial moves aside and finalizes under a numbered name.
|
||||
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
|
||||
expect(unlink).not.toHaveBeenCalledWith('/downloads/movie.mp4');
|
||||
expect(rename).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
'/downloads/movie (1).mp4.part'
|
||||
);
|
||||
expect(link).toHaveBeenCalledWith(
|
||||
'/downloads/movie (1).mp4.part',
|
||||
'/downloads/movie (1).mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 4,
|
||||
fileName: 'movie (1).mp4',
|
||||
filePath: '/downloads/movie (1).mp4',
|
||||
status: 'completed',
|
||||
totalBytes: 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +1,91 @@
|
||||
import type { DownloadItem } from 'electron';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import {
|
||||
attachDownloadItem,
|
||||
requestDownloadCancellation,
|
||||
type DownloadTask,
|
||||
requestDownloadPause,
|
||||
} from './download-task';
|
||||
import {
|
||||
createTask,
|
||||
waitForCallCount,
|
||||
waitForStatus,
|
||||
waitForStatusCount,
|
||||
} from './download.test-helpers';
|
||||
|
||||
function createTask(): DownloadTask {
|
||||
return {
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
id: 42,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForCallCount(
|
||||
mock: jest.Mock,
|
||||
expectedCallCount: number
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (mock.mock.calls.length === expectedCallCount) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
|
||||
}
|
||||
|
||||
describe('download runtime cancellation', () => {
|
||||
it('cancels the item when an earlier cancellation request reaches onStarted', () => {
|
||||
const task = createTask();
|
||||
const cancel = jest.fn();
|
||||
|
||||
requestDownloadCancellation(task);
|
||||
attachDownloadItem(task, { cancel } as unknown as DownloadItem);
|
||||
|
||||
expect(task.cancelRequested).toBe(true);
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels an already-started item immediately', () => {
|
||||
const cancel = jest.fn();
|
||||
describe('download task interruption', () => {
|
||||
it('aborts an active task when cancellation is requested', () => {
|
||||
const abort = jest.fn();
|
||||
const task = {
|
||||
...createTask(),
|
||||
downloadItem: { cancel } as unknown as DownloadItem,
|
||||
abortController: { abort } as unknown as AbortController,
|
||||
};
|
||||
|
||||
requestDownloadCancellation(task);
|
||||
|
||||
expect(task.cancelRequested).toBe(true);
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('continues the queue when cancellation persistence fails', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
class TestCancelError extends Error {}
|
||||
|
||||
let cancellationCallback: Promise<void> | undefined;
|
||||
const download = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCancel: (item: DownloadItem) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
cancellationCallback = options.onCancel({
|
||||
getSavePath: () => '/downloads/movie.mp4',
|
||||
} as unknown as DownloadItem);
|
||||
throw new TestCancelError();
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
await options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'second.mp4',
|
||||
path: '/downloads/second.mp4',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const where = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('database is busy'))
|
||||
.mockResolvedValue(undefined);
|
||||
const db = {
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({ where })),
|
||||
})),
|
||||
it('aborts an active task when pause is requested', () => {
|
||||
const abort = jest.fn();
|
||||
const task = {
|
||||
...createTask(),
|
||||
abortController: { abort } as unknown as AbortController,
|
||||
};
|
||||
|
||||
jest.doMock('electron-dl', () => ({
|
||||
CancelError: TestCancelError,
|
||||
download,
|
||||
}));
|
||||
requestDownloadPause(task);
|
||||
|
||||
expect(task.pauseRequested).toBe(true);
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download runtime pause and resume', () => {
|
||||
it('persists active pause without deleting the partial file', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const stream = new PassThrough();
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async (
|
||||
_url: string,
|
||||
options: { signal?: AbortSignal }
|
||||
) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
stream.destroy(new Error('aborted'));
|
||||
});
|
||||
return {
|
||||
data: stream,
|
||||
headers: { 'content-length': '100' },
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownload: jest.fn(),
|
||||
reserveAvailableDownloadFile: jest.fn(
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValue(25),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
@@ -127,96 +94,134 @@ describe('download runtime cancellation', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
await expect(runtime.pauseDownload(42)).resolves.toBe(true);
|
||||
await waitForStatus(set, 'paused');
|
||||
|
||||
expect(removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 25,
|
||||
status: 'paused',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses an HTTP Range header when a partial file already exists', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
data: Readable.from([Buffer.from('rest')]),
|
||||
headers: { 'content-range': 'bytes 50-53/54' },
|
||||
status: 206,
|
||||
}) as never
|
||||
);
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('node:fs/promises', () => ({
|
||||
copyFile: jest.fn(async () => undefined),
|
||||
link: jest.fn(async () => undefined),
|
||||
stat: jest.fn(async () => ({ size: 54 })),
|
||||
unlink: jest.fn(async () => undefined),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 50),
|
||||
removePartialDownloadFile: jest.fn(),
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
fileName: 'second.mp4',
|
||||
id: 43,
|
||||
filePath: '/downloads/movie.mp4',
|
||||
});
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
await waitForCallCount(download, 2);
|
||||
await expect(cancellationCallback).resolves.toBeUndefined();
|
||||
|
||||
consoleError.mockRestore();
|
||||
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Range: 'bytes=50-' }),
|
||||
}),
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
await waitForStatus(set, 'completed');
|
||||
});
|
||||
|
||||
it('continues the queue when completion persistence fails', async () => {
|
||||
it('deletes a queued resumed partial file when the queued task is canceled', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
class TestCancelError extends Error {}
|
||||
|
||||
let completionCallback: Promise<void> | undefined;
|
||||
const download = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
completionCallback = options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'movie.mp4',
|
||||
path: '/downloads/movie.mp4',
|
||||
});
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
await options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'second.mp4',
|
||||
path: '/downloads/second.mp4',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const where = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('database is busy'))
|
||||
.mockResolvedValue(undefined);
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({ where })),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const activeStream = new PassThrough();
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async (_url: string, options: { signal?: AbortSignal }) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
activeStream.destroy(new Error('aborted'));
|
||||
});
|
||||
return {
|
||||
data: activeStream,
|
||||
headers: { 'content-length': '100' },
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.doMock('electron-dl', () => ({
|
||||
CancelError: TestCancelError,
|
||||
download,
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects,
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownload: jest.fn(),
|
||||
reserveAvailableDownloadFile: jest.fn(
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
@@ -233,64 +238,66 @@ describe('download runtime cancellation', () => {
|
||||
} as never);
|
||||
|
||||
runtime.enqueueDownload(createTask());
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
fileName: 'second.mp4',
|
||||
fileName: 'resume.mp4',
|
||||
filePath: '/downloads/resume.mp4',
|
||||
id: 43,
|
||||
});
|
||||
|
||||
await waitForCallCount(download, 2);
|
||||
await expect(completionCallback).resolves.toBeUndefined();
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/resume.mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 0,
|
||||
filePath: null,
|
||||
status: 'canceled',
|
||||
totalBytes: null,
|
||||
})
|
||||
);
|
||||
|
||||
await runtime.cancelDownload(42);
|
||||
await waitForStatusCount(set, 'canceled', 2);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues the queue when initial database access fails', async () => {
|
||||
it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
class TestCancelError extends Error {}
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
});
|
||||
|
||||
const download = jest.fn(
|
||||
async (
|
||||
_window: unknown,
|
||||
_url: string,
|
||||
options: {
|
||||
onCompleted: (file: {
|
||||
fileSize: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
await options.onCompleted({
|
||||
fileSize: 1,
|
||||
filename: 'second.mp4',
|
||||
path: '/downloads/second.mp4',
|
||||
});
|
||||
}
|
||||
);
|
||||
const where = jest.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({ where })),
|
||||
})),
|
||||
};
|
||||
const getDatabase = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
.mockResolvedValue(db);
|
||||
|
||||
jest.doMock('electron-dl', () => ({
|
||||
CancelError: TestCancelError,
|
||||
download,
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../../util/validated-axios', () => ({
|
||||
requestWithValidatedRedirects: jest.fn(
|
||||
() => new Promise(() => undefined)
|
||||
),
|
||||
}));
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual('node:fs'),
|
||||
createWriteStream: jest.fn(() => new PassThrough()),
|
||||
existsSync: jest.fn(() => false),
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({ getDatabase }));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownload: jest.fn(),
|
||||
reserveAvailableDownloadFile: jest.fn(
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
filename,
|
||||
partialPath: `${directory}/${filename}.part`,
|
||||
path: `${directory}/${filename}`,
|
||||
})
|
||||
),
|
||||
@@ -306,18 +313,81 @@ describe('download runtime cancellation', () => {
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
// Occupy the active slot so the second task stays queued.
|
||||
runtime.enqueueDownload(createTask());
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
fileName: 'second.mp4',
|
||||
fileName: 'resume.mp4',
|
||||
filePath: '/downloads/resume.mp4',
|
||||
id: 43,
|
||||
});
|
||||
|
||||
await waitForCallCount(download, 1);
|
||||
expect(download).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({ filename: 'second.mp4' })
|
||||
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
|
||||
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/downloads/resume.mp4',
|
||||
status: 'canceled',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([
|
||||
{
|
||||
filePath: '/downloads/paused.mp4',
|
||||
status: 'paused',
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
});
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => 0),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const runtime = await import('./download-runtime');
|
||||
runtime.setMainWindow({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: jest.fn() },
|
||||
} as never);
|
||||
|
||||
await expect(runtime.cancelDownload(77)).resolves.toBe(true);
|
||||
|
||||
expect(removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/paused.mp4'
|
||||
);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/downloads/paused.mp4',
|
||||
status: 'canceled',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { CancelError, download } from 'electron-dl';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rename } from 'node:fs/promises';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { broadcastDownloadUpdate } from './download-broadcast';
|
||||
import {
|
||||
removePartialDownload,
|
||||
reserveAvailableDownloadFile,
|
||||
findAvailableFinalPath,
|
||||
getPartialDownloadPath,
|
||||
reserveAvailablePartialDownloadFile,
|
||||
type ReservedPartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
import {
|
||||
attachDownloadItem,
|
||||
completeDownloadFromPartial,
|
||||
getCompletedPartialProgress,
|
||||
getPausedByteCount,
|
||||
handleDownloadFailure,
|
||||
removePartialFile,
|
||||
} from './download-finalize';
|
||||
import {
|
||||
requestDownloadCancellation,
|
||||
requestDownloadPause,
|
||||
type DownloadsDatabase,
|
||||
type DownloadTask,
|
||||
} from './download-task';
|
||||
import { describeError, transferToPartialFile } from './download-transfer';
|
||||
|
||||
export {
|
||||
broadcastDownloadUpdate,
|
||||
setMainWindow,
|
||||
} from './download-broadcast';
|
||||
|
||||
const downloadQueue: DownloadTask[] = [];
|
||||
let activeDownload: DownloadTask | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
export function setMainWindow(win: BrowserWindow): void {
|
||||
mainWindow = win;
|
||||
}
|
||||
|
||||
export function broadcastDownloadUpdate(): void {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueueDownload(task: DownloadTask): void {
|
||||
// A duplicate id (e.g. two rapid Resume clicks racing the status
|
||||
// refresh) must not produce two transfers for the same row.
|
||||
if (
|
||||
activeDownload?.id === task.id ||
|
||||
downloadQueue.some((queued) => queued.id === task.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadQueue.push(task);
|
||||
broadcastDownloadUpdate();
|
||||
void processQueue();
|
||||
}
|
||||
|
||||
export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
export async function pauseDownload(downloadId: number): Promise<boolean> {
|
||||
if (activeDownload?.id === downloadId) {
|
||||
requestDownloadCancellation(activeDownload);
|
||||
requestDownloadPause(activeDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -52,8 +67,7 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
filePath: null,
|
||||
status: 'canceled',
|
||||
status: 'paused',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
@@ -61,6 +75,52 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cancelDownload(downloadId: number): Promise<boolean> {
|
||||
if (activeDownload?.id === downloadId) {
|
||||
requestDownloadCancellation(activeDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
const queueIndex = downloadQueue.findIndex(
|
||||
(task) => task.id === downloadId
|
||||
);
|
||||
if (queueIndex !== -1) {
|
||||
const [queuedTask] = downloadQueue.splice(queueIndex, 1);
|
||||
const removed = removePartialFile(queuedTask?.filePath);
|
||||
const db = await getDatabase();
|
||||
await persistQueuedCancellation(
|
||||
db,
|
||||
downloadId,
|
||||
removed ? null : (queuedTask?.filePath ?? null)
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const rows = await db
|
||||
.select({
|
||||
filePath: schema.downloads.filePath,
|
||||
status: schema.downloads.status,
|
||||
})
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
const item = rows[0];
|
||||
if (item?.status !== 'paused') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removed = removePartialFile(item.filePath);
|
||||
await persistQueuedCancellation(
|
||||
db,
|
||||
downloadId,
|
||||
removed ? null : item.filePath
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeDownloadFromRuntime(downloadId: number): void {
|
||||
if (activeDownload?.id === downloadId) {
|
||||
requestDownloadCancellation(activeDownload);
|
||||
@@ -90,7 +150,7 @@ async function processQueue(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Downloads] Unhandled error for ${task.fileName}:`,
|
||||
error
|
||||
describeError(error)
|
||||
);
|
||||
finishTask(task);
|
||||
}
|
||||
@@ -104,39 +164,25 @@ function finishTask(task: DownloadTask): void {
|
||||
void processQueue();
|
||||
}
|
||||
|
||||
function createCancellationHandler(
|
||||
task: DownloadTask,
|
||||
db: Awaited<ReturnType<typeof getDatabase>>,
|
||||
reservation: ReturnType<typeof reserveAvailableDownloadFile>
|
||||
): (item: Parameters<typeof removePartialDownload>[0]) => Promise<void> {
|
||||
let cancellationPromise: Promise<void> | undefined;
|
||||
|
||||
return (item) => {
|
||||
cancellationPromise ??= (async () => {
|
||||
console.log(`[Downloads] Canceled: ${reservation.filename}`);
|
||||
removePartialFile(item);
|
||||
try {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
filePath: null,
|
||||
status: 'canceled',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to persist cancellation:',
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
finishTask(task);
|
||||
}
|
||||
})();
|
||||
|
||||
return cancellationPromise;
|
||||
};
|
||||
async function persistQueuedCancellation(
|
||||
db: DownloadsDatabase,
|
||||
downloadId: number,
|
||||
// Keep the path when the retained .part could not be deleted, so a later
|
||||
// remove/clear can retry the cleanup instead of orphaning the file.
|
||||
retainedFilePath: string | null = null
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
filePath: retainedFilePath,
|
||||
resumeValidator: null,
|
||||
status: 'canceled',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
}
|
||||
|
||||
async function startDownload(task: DownloadTask): Promise<void> {
|
||||
@@ -145,40 +191,26 @@ async function startDownload(task: DownloadTask): Promise<void> {
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: null,
|
||||
filePath: null,
|
||||
status: 'downloading',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
console.error('[Downloads] No main window available');
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage: 'No window available for download',
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
finishTask(task);
|
||||
return;
|
||||
}
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
const progressThrottleMs = 500;
|
||||
let handleCancellation:
|
||||
| ReturnType<typeof createCancellationHandler>
|
||||
| undefined;
|
||||
|
||||
let reservation: ReservedPartialDownloadFile | undefined;
|
||||
try {
|
||||
const reservation = reserveAvailableDownloadFile(
|
||||
task.directory,
|
||||
task.fileName
|
||||
);
|
||||
task.reservedPath = reservation.path;
|
||||
handleCancellation = createCancellationHandler(task, db, reservation);
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
reservation = await reserveTarget(task);
|
||||
task.fileName = reservation.filename;
|
||||
task.filePath = reservation.path;
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
@@ -189,107 +221,131 @@ async function startDownload(task: DownloadTask): Promise<void> {
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
|
||||
const downloadOptions: Parameters<typeof download>[2] = {
|
||||
directory: task.directory,
|
||||
filename: reservation.filename,
|
||||
onStarted: (item) => {
|
||||
console.log(`[Downloads] Started: ${reservation.filename}`);
|
||||
attachDownloadItem(task, item);
|
||||
},
|
||||
onProgress: async (progress) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < progressThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastProgressUpdate = now;
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.transferredBytes,
|
||||
totalBytes: progress.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
},
|
||||
onCompleted: async (file) => {
|
||||
console.log(`[Downloads] Completed: ${file.filename}`);
|
||||
try {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: file.fileSize,
|
||||
errorMessage: null,
|
||||
fileName: file.filename,
|
||||
filePath: file.path,
|
||||
status: 'completed',
|
||||
totalBytes: file.fileSize,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to persist completion:',
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
finishTask(task);
|
||||
}
|
||||
},
|
||||
onCancel: handleCancellation,
|
||||
};
|
||||
|
||||
if (task.headers) {
|
||||
(
|
||||
downloadOptions as typeof downloadOptions & {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
).headers = task.headers;
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
await download(mainWindow, task.url, downloadOptions);
|
||||
} catch (error) {
|
||||
if (error instanceof CancelError) {
|
||||
const reservedPath = task.reservedPath;
|
||||
if (handleCancellation) {
|
||||
await handleCancellation(
|
||||
task.downloadItem ??
|
||||
(reservedPath
|
||||
? { getSavePath: () => reservedPath }
|
||||
: undefined)
|
||||
);
|
||||
} else {
|
||||
finishTask(task);
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
|
||||
const reservedPath = task.reservedPath;
|
||||
removePartialFile(
|
||||
task.downloadItem ??
|
||||
(reservedPath ? { getSavePath: () => reservedPath } : undefined)
|
||||
);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
const completedPartialProgress = getCompletedPartialProgress(task);
|
||||
if (completedPartialProgress) {
|
||||
await completeDownloadFromPartial(
|
||||
db,
|
||||
task,
|
||||
reservation,
|
||||
completedPartialProgress
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = await transferToPartialFile(db, task, reservation);
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
await completeDownloadFromPartial(db, task, reservation, progress);
|
||||
} catch (error) {
|
||||
if (task.cancelRequested) {
|
||||
await persistCancellation(db, task);
|
||||
return;
|
||||
}
|
||||
if (task.pauseRequested) {
|
||||
await persistPause(db, task);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDownloadFailure(db, task, reservation, error);
|
||||
} finally {
|
||||
task.abortController = undefined;
|
||||
finishTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
function removePartialFile(
|
||||
item: Parameters<typeof removePartialDownload>[0]
|
||||
): void {
|
||||
async function reserveTarget(
|
||||
task: DownloadTask
|
||||
): Promise<ReservedPartialDownloadFile> {
|
||||
if (task.filePath) {
|
||||
if (!existsSync(task.filePath)) {
|
||||
return {
|
||||
filename: task.fileName,
|
||||
partialPath: getPartialDownloadPath(task.filePath),
|
||||
path: task.filePath,
|
||||
};
|
||||
}
|
||||
|
||||
// Something now occupies the recorded destination — possibly a file
|
||||
// the user created while this download was paused or failed. Never
|
||||
// inspect or delete it: move the retained .part to the next free
|
||||
// numbered destination and finalize there instead.
|
||||
const redirected = findAvailableFinalPath(task.filePath);
|
||||
const currentPartial = getPartialDownloadPath(task.filePath);
|
||||
const redirectedPartial = getPartialDownloadPath(redirected.path);
|
||||
if (existsSync(currentPartial)) {
|
||||
await rename(currentPartial, redirectedPartial);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: redirected.filename,
|
||||
partialPath: redirectedPartial,
|
||||
path: redirected.path,
|
||||
};
|
||||
}
|
||||
|
||||
return reserveAvailablePartialDownloadFile(task.directory, task.fileName);
|
||||
}
|
||||
|
||||
async function persistCancellation(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask
|
||||
): Promise<void> {
|
||||
console.log(`[Downloads] Canceled: ${task.fileName}`);
|
||||
const removed = removePartialFile(task.filePath);
|
||||
try {
|
||||
removePartialDownload(item);
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: 0,
|
||||
errorMessage: null,
|
||||
filePath: removed ? null : (task.filePath ?? null),
|
||||
resumeValidator: null,
|
||||
status: 'canceled',
|
||||
totalBytes: null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to delete partial file:', error);
|
||||
console.error('[Downloads] Failed to persist cancellation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistPause(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask
|
||||
): Promise<void> {
|
||||
console.log(`[Downloads] Paused: ${task.fileName}`);
|
||||
const bytesDownloaded = getPausedByteCount(task);
|
||||
try {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded,
|
||||
errorMessage: null,
|
||||
fileName: task.fileName,
|
||||
filePath: task.filePath ?? null,
|
||||
status: 'paused',
|
||||
totalBytes: task.totalBytes ?? null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Failed to persist pause:', error);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { DownloadItem } from 'electron';
|
||||
import type { getDatabase } from '../../database/connection';
|
||||
|
||||
export type DownloadsDatabase = Awaited<ReturnType<typeof getDatabase>>;
|
||||
|
||||
export interface TransferProgress {
|
||||
bytesDownloaded: number;
|
||||
totalBytes: number | null;
|
||||
}
|
||||
|
||||
export interface CompletedPartialProgress extends TransferProgress {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface DownloadTask {
|
||||
id: number;
|
||||
@@ -7,21 +18,20 @@ export interface DownloadTask {
|
||||
directory: string;
|
||||
headers?: Record<string, string>;
|
||||
cancelRequested?: boolean;
|
||||
downloadItem?: DownloadItem;
|
||||
reservedPath?: string;
|
||||
}
|
||||
|
||||
export function attachDownloadItem(
|
||||
task: DownloadTask,
|
||||
item: DownloadItem
|
||||
): void {
|
||||
task.downloadItem = item;
|
||||
if (task.cancelRequested) {
|
||||
item.cancel();
|
||||
}
|
||||
pauseRequested?: boolean;
|
||||
abortController?: AbortController;
|
||||
filePath?: string | null;
|
||||
totalBytes?: number | null;
|
||||
/** ETag/Last-Modified of the entity the partial belongs to (If-Range). */
|
||||
resumeValidator?: string | null;
|
||||
}
|
||||
|
||||
export function requestDownloadCancellation(task: DownloadTask): void {
|
||||
task.cancelRequested = true;
|
||||
task.downloadItem?.cancel();
|
||||
task.abortController?.abort();
|
||||
}
|
||||
|
||||
export function requestDownloadPause(task: DownloadTask): void {
|
||||
task.pauseRequested = true;
|
||||
task.abortController?.abort();
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import * as schema from '../../database/schema';
|
||||
import { requestWithValidatedRedirects } from '../../util/validated-axios';
|
||||
import { broadcastDownloadUpdate } from './download-broadcast';
|
||||
import {
|
||||
getPartialDownloadSize,
|
||||
type ReservedPartialDownloadFile,
|
||||
} from './download-file-path';
|
||||
import type {
|
||||
DownloadsDatabase,
|
||||
DownloadTask,
|
||||
TransferProgress,
|
||||
} from './download-task';
|
||||
|
||||
/**
|
||||
* Log transfer failures by message only: a raw AxiosError dumps its request
|
||||
* config, and download URLs can embed portal credentials.
|
||||
*/
|
||||
export function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* The response stream ended cleanly before the advertised representation
|
||||
* size was reached (e.g. a proxy that caps each response). The partial is
|
||||
* valid — the caller must retain it so a retry can continue via Range.
|
||||
*/
|
||||
export class TruncatedTransferError extends Error {
|
||||
constructor(readonly progress: TransferProgress) {
|
||||
super('Transfer ended before the advertised size');
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferToPartialFile(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile
|
||||
): Promise<TransferProgress> {
|
||||
const resumeOffset = getResumeOffset(task, reservation);
|
||||
|
||||
const headers = {
|
||||
...(task.headers ?? {}),
|
||||
};
|
||||
if (resumeOffset > 0) {
|
||||
headers.Range = `bytes=${resumeOffset}-`;
|
||||
if (task.resumeValidator) {
|
||||
headers['If-Range'] = task.resumeValidator;
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
task.abortController = abortController;
|
||||
if (task.cancelRequested || task.pauseRequested) {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
console.log(`[Downloads] Started: ${reservation.filename}`);
|
||||
const response = await requestWithValidatedRedirects<Readable>(
|
||||
task.url,
|
||||
{
|
||||
headers,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
signal: abortController.signal,
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
},
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
|
||||
const readable = response.data;
|
||||
let effectiveOffset = resumeOffset;
|
||||
try {
|
||||
effectiveOffset = validateResumeResponse(
|
||||
reservation,
|
||||
response.status,
|
||||
response.headers,
|
||||
resumeOffset
|
||||
);
|
||||
} catch (error) {
|
||||
// Abandon the unconsumed response body; swallow its error events so
|
||||
// destroying a stream nobody is piping cannot crash the process.
|
||||
readable.on('error', () => undefined);
|
||||
readable.destroy();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const totalBytes = getTotalBytes(response.headers, effectiveOffset);
|
||||
task.totalBytes = totalBytes;
|
||||
if (effectiveOffset === 0) {
|
||||
task.resumeValidator = getResponseValidator(response.headers);
|
||||
}
|
||||
await persistTransferStart(db, task, effectiveOffset, totalBytes);
|
||||
|
||||
let bytesDownloaded = effectiveOffset;
|
||||
let lastProgressUpdate = 0;
|
||||
const progressThrottleMs = 500;
|
||||
const output = createWriteStream(reservation.partialPath, {
|
||||
flags: effectiveOffset > 0 ? 'a' : 'w',
|
||||
});
|
||||
const abortStream = () => {
|
||||
readable.destroy(new Error('Download aborted'));
|
||||
};
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
abortStream();
|
||||
} else {
|
||||
abortController.signal.addEventListener('abort', abortStream, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
readable.on('data', (chunk: Buffer | string) => {
|
||||
bytesDownloaded += Buffer.isBuffer(chunk)
|
||||
? chunk.length
|
||||
: Buffer.byteLength(chunk);
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate < progressThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastProgressUpdate = now;
|
||||
void persistProgress(db, task, {
|
||||
bytesDownloaded,
|
||||
totalBytes,
|
||||
}).catch((error) => {
|
||||
console.error('[Downloads] Failed to persist progress:', error);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(readable, output);
|
||||
} finally {
|
||||
abortController.signal.removeEventListener('abort', abortStream);
|
||||
}
|
||||
|
||||
await persistProgress(db, task, { bytesDownloaded, totalBytes });
|
||||
if (totalBytes !== null && bytesDownloaded < totalBytes) {
|
||||
throw new TruncatedTransferError({ bytesDownloaded, totalBytes });
|
||||
}
|
||||
return { bytesDownloaded, totalBytes };
|
||||
}
|
||||
|
||||
function getResumeOffset(
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile
|
||||
): number {
|
||||
const resumeOffset = getPartialDownloadSize(reservation.path);
|
||||
if (
|
||||
task.totalBytes !== null &&
|
||||
task.totalBytes !== undefined &&
|
||||
resumeOffset > task.totalBytes
|
||||
) {
|
||||
throw new Error('Partial download is larger than expected');
|
||||
}
|
||||
return resumeOffset;
|
||||
}
|
||||
|
||||
function validateResumeResponse(
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
status: number,
|
||||
headers: unknown,
|
||||
resumeOffset: number
|
||||
): number {
|
||||
if (resumeOffset === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (status !== 206) {
|
||||
// Either the server ignored Range or If-Range detected that the
|
||||
// remote entity changed. The retained partial is unusable either way,
|
||||
// so restart from byte zero instead of failing the download.
|
||||
console.warn(
|
||||
`[Downloads] Restarting ${reservation.filename} from the beginning (resume request answered with HTTP ${status})`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const contentRange = getHeaderValue(
|
||||
headers as Record<string, unknown>,
|
||||
'content-range'
|
||||
);
|
||||
const start = contentRange?.match(/^bytes\s+(\d+)-/i)?.[1];
|
||||
if (start === undefined || Number(start) !== resumeOffset) {
|
||||
throw new Error('Server returned an invalid resume range');
|
||||
}
|
||||
return resumeOffset;
|
||||
}
|
||||
|
||||
function getResponseValidator(headers: unknown): string | null {
|
||||
const headerMap = headers as Record<string, unknown>;
|
||||
const etag = getHeaderValue(headerMap, 'etag');
|
||||
// If-Range only accepts strong validators, so skip weak W/ ETags.
|
||||
if (etag && !etag.startsWith('W/')) {
|
||||
return etag;
|
||||
}
|
||||
return getHeaderValue(headerMap, 'last-modified') ?? null;
|
||||
}
|
||||
|
||||
async function persistTransferStart(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
bytesDownloaded: number,
|
||||
totalBytes: number | null
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded,
|
||||
resumeValidator: task.resumeValidator ?? null,
|
||||
totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
}
|
||||
|
||||
async function persistProgress(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
progress: TransferProgress
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.downloads)
|
||||
.set({
|
||||
bytesDownloaded: progress.bytesDownloaded,
|
||||
totalBytes: progress.totalBytes,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(schema.downloads.id, task.id));
|
||||
broadcastDownloadUpdate();
|
||||
}
|
||||
|
||||
function getTotalBytes(headers: unknown, resumeOffset: number): number | null {
|
||||
const headerMap = headers as Record<string, unknown>;
|
||||
const contentRange = getHeaderValue(headerMap, 'content-range');
|
||||
if (contentRange) {
|
||||
const match = contentRange.match(/\/(\d+)$/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = getHeaderValue(headerMap, 'content-length');
|
||||
if (!contentLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(contentLength);
|
||||
return Number.isFinite(parsed) ? resumeOffset + parsed : null;
|
||||
}
|
||||
|
||||
function getHeaderValue(
|
||||
headers: Record<string, unknown>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const value = headers[name] ?? headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? String(value[0]) : undefined;
|
||||
}
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { DownloadTask } from './download-task';
|
||||
|
||||
export function createTask(): DownloadTask {
|
||||
return {
|
||||
directory: '/downloads',
|
||||
fileName: 'movie.mp4',
|
||||
id: 42,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForCallCount(
|
||||
mock: jest.Mock,
|
||||
expectedCallCount: number
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (mock.mock.calls.length === expectedCallCount) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
|
||||
}
|
||||
|
||||
export async function waitForStatus(
|
||||
set: jest.Mock,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
if (set.mock.calls.some(([value]) => value?.status === status)) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ status }));
|
||||
}
|
||||
|
||||
export async function waitForStatusCount(
|
||||
set: jest.Mock,
|
||||
status: string,
|
||||
expectedCount: number
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const statusCount = set.mock.calls.filter(
|
||||
([value]) => value?.status === status
|
||||
).length;
|
||||
if (statusCount >= expectedCount) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
expect(
|
||||
set.mock.calls.filter(([value]) => value?.status === status)
|
||||
).toHaveLength(expectedCount);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
const mockRegisteredHandlers = new Map<string, IpcHandler>();
|
||||
const mockGetDatabase = jest.fn();
|
||||
const mockRemoveDownloadFromRuntime = jest.fn();
|
||||
const mockBroadcastDownloadUpdate = jest.fn();
|
||||
const mockRemovePartialDownloadFile = jest.fn();
|
||||
const mockPauseDownload = jest.fn();
|
||||
const mockResumeDownloadRequest = jest.fn();
|
||||
|
||||
function getHandler(channel: string): IpcHandler {
|
||||
const handler = mockRegisteredHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected IPC handler for ${channel}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
function createDownloadRow(status: string) {
|
||||
return {
|
||||
filePath: '/downloads/resume.mp4',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe('downloads events', () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockRegisteredHandlers.clear();
|
||||
mockGetDatabase.mockReset();
|
||||
mockRemoveDownloadFromRuntime.mockReset();
|
||||
mockBroadcastDownloadUpdate.mockReset();
|
||||
mockRemovePartialDownloadFile.mockReset();
|
||||
mockPauseDownload.mockReset();
|
||||
mockResumeDownloadRequest.mockReset();
|
||||
|
||||
jest.doMock('electron', () => ({
|
||||
app: {
|
||||
getPath: jest.fn((name: string) =>
|
||||
name === 'userData' ? '/user-data' : '/downloads'
|
||||
),
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: jest.fn(),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: IpcHandler) => {
|
||||
mockRegisteredHandlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
openPath: jest.fn(),
|
||||
showItemInFolder: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: mockGetDatabase,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile: mockRemovePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
|
||||
cancelDownload: jest.fn(),
|
||||
pauseDownload: mockPauseDownload,
|
||||
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
|
||||
setMainWindow: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-requests', () => ({
|
||||
resumeDownloadRequest: mockResumeDownloadRequest,
|
||||
retryDownloadRequest: jest.fn(),
|
||||
startDownloadRequest: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-recovery', () => ({
|
||||
resetStaleDownloads: jest.fn(),
|
||||
}));
|
||||
|
||||
await import('./downloads.events');
|
||||
});
|
||||
|
||||
function mockDownloadRow(row: { filePath: string | null; status: string }) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([row]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
function mockTerminalRows(
|
||||
rows: Array<{ filePath: string | null; status: string }>
|
||||
) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const selectWhere = jest
|
||||
.fn()
|
||||
.mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row })));
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: selectWhere,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere, selectWhere };
|
||||
}
|
||||
|
||||
it('removes queued resumed partial files before deleting the row', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
|
||||
|
||||
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/resume.mp4'
|
||||
);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
|
||||
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes completed partial files before deleting the row', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('completed'));
|
||||
|
||||
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/resume.mp4'
|
||||
);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('keeps the queued runtime entry and row when partial cleanup fails', async () => {
|
||||
const cleanupError = new Error('permission denied');
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
|
||||
mockRemovePartialDownloadFile.mockImplementation(() => {
|
||||
throw cleanupError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
// The row and its .part must survive, but the renderer gets a
|
||||
// structured failure it can surface instead of an IPC rejection.
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REMOVE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Could not delete the partial file',
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(deleteWhere).not.toHaveBeenCalled();
|
||||
expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes completed, failed, and canceled partial files before clearing terminal downloads', async () => {
|
||||
const { deleteWhere } = mockTerminalRows([
|
||||
{ filePath: '/downloads/done.mp4', status: 'completed' },
|
||||
{ filePath: '/downloads/failed.mp4', status: 'failed' },
|
||||
{ filePath: '/downloads/canceled.mp4', status: 'canceled' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(3);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/downloads/done.mp4'
|
||||
);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/downloads/failed.mp4'
|
||||
);
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/downloads/canceled.mp4'
|
||||
);
|
||||
expect(
|
||||
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
|
||||
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retains only downloads whose partial cleanup fails when clearing terminal downloads', async () => {
|
||||
const cleanupError = new Error('permission denied');
|
||||
const { deleteWhere } = mockTerminalRows([
|
||||
{ filePath: '/downloads/done.mp4', status: 'completed' },
|
||||
{ filePath: '/downloads/failed.mp4', status: 'failed' },
|
||||
]);
|
||||
mockRemovePartialDownloadFile.mockImplementation((filePath) => {
|
||||
if (filePath !== '/downloads/failed.mp4') {
|
||||
return;
|
||||
}
|
||||
throw cleanupError;
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1);
|
||||
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes the row once a previously locked partial becomes deletable', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('paused'));
|
||||
mockRemovePartialDownloadFile
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
})
|
||||
.mockImplementation(() => true);
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REMOVE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Could not delete the partial file',
|
||||
success: false,
|
||||
});
|
||||
expect(deleteWhere).not.toHaveBeenCalled();
|
||||
|
||||
// Retry after the lock is released: cleanup and delete succeed.
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REMOVE')(null, 42)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps a successful runtime pause to a success response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(true);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockPauseDownload).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('maps an unknown pause target to an error response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(false);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Download not found in queue',
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards resume requests with the download folder and returns the result', async () => {
|
||||
mockResumeDownloadRequest.mockResolvedValue({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
|
||||
).resolves.toEqual({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import { join } from 'node:path';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
import * as schema from '../../database/schema';
|
||||
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import {
|
||||
resumeDownloadRequest,
|
||||
retryDownloadRequest,
|
||||
startDownloadRequest,
|
||||
type StartDownloadRequest,
|
||||
@@ -15,10 +17,19 @@ import { resetStaleDownloads } from './download-recovery';
|
||||
import {
|
||||
broadcastDownloadUpdate,
|
||||
cancelDownload,
|
||||
pauseDownload,
|
||||
removeDownloadFromRuntime,
|
||||
setMainWindow,
|
||||
} from './download-runtime';
|
||||
|
||||
const removablePartialStatuses = new Set([
|
||||
'queued',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
]);
|
||||
|
||||
function getDownloadAuthorizationPath(): string {
|
||||
return join(
|
||||
app.getPath('userData'),
|
||||
@@ -104,6 +115,34 @@ ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('DOWNLOADS_PAUSE', async (_event, downloadId: number) => {
|
||||
try {
|
||||
console.log('[Downloads] Pause download:', downloadId);
|
||||
return (await pauseDownload(downloadId))
|
||||
? { success: true }
|
||||
: { error: 'Download not found in queue', success: false };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error pausing download:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'DOWNLOADS_RESUME',
|
||||
async (_event, downloadId: number, downloadFolder: string) => {
|
||||
try {
|
||||
return await resumeDownloadRequest(
|
||||
downloadId,
|
||||
downloadFolder,
|
||||
downloadDirectoryAuthorizer
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error resuming download:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'DOWNLOADS_RETRY',
|
||||
async (_event, downloadId: number, downloadFolder: string) => {
|
||||
@@ -123,8 +162,36 @@ ipcMain.handle(
|
||||
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
|
||||
try {
|
||||
console.log('[Downloads] Remove download:', downloadId);
|
||||
removeDownloadFromRuntime(downloadId);
|
||||
const db = await getDatabase();
|
||||
const rows = await db
|
||||
.select({
|
||||
filePath: schema.downloads.filePath,
|
||||
status: schema.downloads.status,
|
||||
})
|
||||
.from(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (row?.filePath && removablePartialStatuses.has(row.status)) {
|
||||
try {
|
||||
removePartialDownloadFile(row.filePath);
|
||||
} catch (cleanupError) {
|
||||
// Keep the row (and its runtime entry) so the .part is never
|
||||
// orphaned, but answer with a structured failure the UI can
|
||||
// surface instead of an opaque IPC rejection. Retrying the
|
||||
// remove re-attempts the deletion.
|
||||
console.error(
|
||||
'[Downloads] Failed to delete partial file on remove:',
|
||||
row.filePath,
|
||||
cleanupError
|
||||
);
|
||||
return {
|
||||
error: 'Could not delete the partial file',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
removeDownloadFromRuntime(downloadId);
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(eq(schema.downloads.id, downloadId));
|
||||
@@ -210,17 +277,43 @@ ipcMain.handle(
|
||||
'failed',
|
||||
'canceled',
|
||||
]);
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(
|
||||
playlistId
|
||||
? and(
|
||||
eq(schema.downloads.playlistId, playlistId),
|
||||
terminalStatus
|
||||
)
|
||||
: terminalStatus
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
const terminalFilter = playlistId
|
||||
? and(eq(schema.downloads.playlistId, playlistId), terminalStatus)
|
||||
: terminalStatus;
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.downloads.id,
|
||||
filePath: schema.downloads.filePath,
|
||||
status: schema.downloads.status,
|
||||
})
|
||||
.from(schema.downloads)
|
||||
.where(terminalFilter);
|
||||
const downloadIdsToDelete: number[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.filePath && removablePartialStatuses.has(row.status)) {
|
||||
try {
|
||||
removePartialDownloadFile(row.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Retaining download after partial cleanup failed:',
|
||||
error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
downloadIdsToDelete.push(row.id);
|
||||
}
|
||||
if (downloadIdsToDelete.length > 0) {
|
||||
await db
|
||||
.delete(schema.downloads)
|
||||
.where(
|
||||
and(
|
||||
terminalFilter,
|
||||
inArray(schema.downloads.id, downloadIdsToDelete)
|
||||
)
|
||||
);
|
||||
broadcastDownloadUpdate();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Downloads] Error clearing completed:', error);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { removePartialDownload } from './download-file-path';
|
||||
|
||||
interface StaleDownloadFile {
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
function removePersistedPartial(filePath: string): void {
|
||||
removePartialDownload({ getSavePath: () => filePath });
|
||||
}
|
||||
|
||||
export function cleanupStaleDownloadFiles(
|
||||
downloads: readonly StaleDownloadFile[],
|
||||
removeFile: (filePath: string) => void = removePersistedPartial
|
||||
): void {
|
||||
for (const download of downloads) {
|
||||
if (!download.filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
removeFile(download.filePath);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[Downloads] Failed to delete stale partial file:',
|
||||
download.filePath,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,16 +66,35 @@ function createLimitedSelectChain(
|
||||
limitResult: unknown,
|
||||
whereCalls: unknown[]
|
||||
): { from: jest.Mock } {
|
||||
const limit = jest.fn().mockResolvedValue(limitResult);
|
||||
const chain: Record<string, jest.Mock> = {
|
||||
limit: jest.fn().mockResolvedValue(limitResult),
|
||||
};
|
||||
// groupBy/orderBy are optional links in the various query shapes; each
|
||||
// returns the chain so where().groupBy().orderBy().limit() (and any
|
||||
// subset) resolves to the same data.
|
||||
chain.groupBy = jest.fn(() => chain);
|
||||
chain.orderBy = jest.fn(() => chain);
|
||||
const where = jest.fn((condition: unknown) => {
|
||||
whereCalls.push(condition);
|
||||
return { limit };
|
||||
return chain;
|
||||
});
|
||||
return {
|
||||
from: jest.fn(() => ({ where })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Program-query chain: from().where().groupBy().orderBy().limit(). */
|
||||
function createProgramChain(rows: unknown): { from: jest.Mock } {
|
||||
const chain: Record<string, jest.Mock> = {
|
||||
limit: jest.fn().mockResolvedValue(rows),
|
||||
};
|
||||
chain.groupBy = jest.fn(() => chain);
|
||||
chain.orderBy = jest.fn(() => chain);
|
||||
return {
|
||||
from: jest.fn(() => ({ where: jest.fn(() => chain) })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgQueryService', () => {
|
||||
let service: EpgQueryService;
|
||||
|
||||
@@ -403,4 +422,103 @@ describe('EpgQueryService', () => {
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a manual mapping saved under any stream sharing the epg_channel_id via a single join', async () => {
|
||||
const whereCalls: unknown[] = [];
|
||||
const joinLimit = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ epgChannelId: 'BBC.MAPPED' }]);
|
||||
let innerJoinCount = 0;
|
||||
const joinChain: Record<string, jest.Mock> = {};
|
||||
joinChain.innerJoin = jest.fn(() => {
|
||||
innerJoinCount += 1;
|
||||
return joinChain;
|
||||
});
|
||||
joinChain.where = jest.fn((condition: unknown) => {
|
||||
whereCalls.push(condition);
|
||||
return { orderBy: jest.fn(() => ({ limit: joinLimit })) };
|
||||
});
|
||||
|
||||
// Program lookup for the resolved (mapped) channel id.
|
||||
const programRow = {
|
||||
id: 1,
|
||||
channelId: 'BBC.MAPPED',
|
||||
start: '2026-06-21T17:00:00.000Z',
|
||||
stop: '2026-06-21T18:00:00.000Z',
|
||||
title: 'Mapped Programme',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
sourceUrl: null,
|
||||
};
|
||||
|
||||
const select = jest
|
||||
.fn()
|
||||
// getMapping direct lookup — miss.
|
||||
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
|
||||
// getMapping Xtream fallback — single join across content /
|
||||
// categories / mappings, no arbitrary candidate cap.
|
||||
.mockReturnValueOnce({ from: jest.fn(() => joinChain) })
|
||||
// selectChannelPrograms for the mapped id.
|
||||
.mockReturnValueOnce(createProgramChain([programRow]));
|
||||
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
|
||||
const result = await service.getChannelPrograms('provider.epg.id');
|
||||
|
||||
expect(innerJoinCount).toBe(2);
|
||||
expect(joinLimit).toHaveBeenCalledWith(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Mapped Programme');
|
||||
});
|
||||
|
||||
it('collapses duplicate programme slots from multiple sources in an unscoped lookup', async () => {
|
||||
const whereCalls: unknown[] = [];
|
||||
const dupRow = {
|
||||
id: 1,
|
||||
channelId: 'bbc.one.uk',
|
||||
start: '2026-06-21T20:00:00.000Z',
|
||||
stop: '2026-06-21T21:00:00.000Z',
|
||||
title: 'News',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
};
|
||||
const select = jest
|
||||
.fn()
|
||||
// getMapping direct + Xtream fallback — both miss.
|
||||
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
|
||||
.mockReturnValueOnce({
|
||||
from: jest.fn(() => ({
|
||||
innerJoin: jest.fn(function inner() {
|
||||
return this;
|
||||
}),
|
||||
where: jest.fn(() => ({
|
||||
orderBy: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})
|
||||
// selectChannelPrograms — two sources, same channel/start/title.
|
||||
// The SQL GROUP BY would collapse these; the mock returns both to
|
||||
// prove the JS safety-net dedup also holds.
|
||||
.mockReturnValueOnce(
|
||||
createProgramChain([
|
||||
{ ...dupRow, sourceUrl: 'https://a.example/g.xml' },
|
||||
{ ...dupRow, id: 2, sourceUrl: 'https://b.example/g.xml' },
|
||||
])
|
||||
);
|
||||
|
||||
getDatabase.mockResolvedValue({ select });
|
||||
|
||||
const result = await service.getChannelPrograms('bbc.one.uk');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('News');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import * as schema from '../database/schema';
|
||||
|
||||
@@ -36,13 +39,21 @@ export class EpgQueryService {
|
||||
): Promise<EpgProgram[]> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const trimmedChannelId = channelId.trim();
|
||||
let trimmedChannelId = channelId.trim();
|
||||
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
|
||||
|
||||
if (!trimmedChannelId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check for manual EPG mapping — if a user has mapped this
|
||||
// channel key to a different EPG channel ID, use the mapped
|
||||
// ID for all subsequent lookups.
|
||||
const mapped = await this.getMapping(db, trimmedChannelId);
|
||||
if (mapped) {
|
||||
trimmedChannelId = mapped;
|
||||
}
|
||||
|
||||
let results = await this.selectChannelPrograms(
|
||||
db,
|
||||
trimmedChannelId,
|
||||
@@ -57,9 +68,7 @@ export class EpgQueryService {
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
return results
|
||||
.map(this.transformDbRowToEpgProgram)
|
||||
.filter(this.isValidEpgProgram);
|
||||
return this.toEpgPrograms(results);
|
||||
}
|
||||
|
||||
let channel = await this.selectChannelById(
|
||||
@@ -90,9 +99,7 @@ export class EpgQueryService {
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
return results
|
||||
.map(this.transformDbRowToEpgProgram)
|
||||
.filter(this.isValidEpgProgram);
|
||||
return this.toEpgPrograms(results);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +149,7 @@ export class EpgQueryService {
|
||||
);
|
||||
}
|
||||
|
||||
return results
|
||||
.map(this.transformDbRowToEpgProgram)
|
||||
.filter(this.isValidEpgProgram);
|
||||
return this.toEpgPrograms(results);
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -541,6 +546,13 @@ export class EpgQueryService {
|
||||
sourceUrls
|
||||
)
|
||||
)
|
||||
// Collapse identical slots from multiple sources in SQL so the
|
||||
// 500-row cap counts distinct programmes, not duplicate rows.
|
||||
.groupBy(
|
||||
schema.epgPrograms.channelId,
|
||||
schema.epgPrograms.start,
|
||||
schema.epgPrograms.title
|
||||
)
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500);
|
||||
}
|
||||
@@ -564,6 +576,11 @@ export class EpgQueryService {
|
||||
{ legacyOnly: true }
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
schema.epgPrograms.channelId,
|
||||
schema.epgPrograms.start,
|
||||
schema.epgPrograms.title
|
||||
)
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500);
|
||||
}
|
||||
@@ -595,6 +612,9 @@ export class EpgQueryService {
|
||||
options
|
||||
)
|
||||
)
|
||||
// One current row per channel so duplicate cross-source slots
|
||||
// don't consume the per-channel cap and starve other channels.
|
||||
.groupBy(schema.epgPrograms.channelId)
|
||||
.limit(channelIds.length);
|
||||
}
|
||||
|
||||
@@ -883,6 +903,31 @@ export class EpgQueryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map program rows to EpgProgram and drop invalid ones, then collapse
|
||||
* duplicate programme slots. The dedup index is source-aware, so an
|
||||
* unscoped lookup (e.g. the M3U timeline, which queries without
|
||||
* `sourceUrls`) can return the same channel/start/title from two EPG
|
||||
* sources; keep the first so a programme is never shown twice.
|
||||
*/
|
||||
private toEpgPrograms(rows: EpgProgramRow[]): EpgProgram[] {
|
||||
const seen = new Set<string>();
|
||||
const programs: EpgProgram[] = [];
|
||||
for (const row of rows) {
|
||||
const program = this.transformDbRowToEpgProgram(row);
|
||||
if (!this.isValidEpgProgram(program)) {
|
||||
continue;
|
||||
}
|
||||
const key = `${program.channel}|${program.start}|${program.title}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
programs.push(program);
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
private transformDbRowToEpgProgram(row: EpgProgramRow): EpgProgram {
|
||||
return {
|
||||
start: row.start,
|
||||
@@ -905,6 +950,75 @@ export class EpgQueryService {
|
||||
!Number.isNaN(new Date(program.stop).getTime())
|
||||
);
|
||||
}
|
||||
|
||||
private async getMapping(
|
||||
db: EpgDatabase,
|
||||
channelKey: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Direct lookup by channel key.
|
||||
const rows = await db
|
||||
.select({
|
||||
epgChannelId: schema.epgChannelMappings.epgChannelId,
|
||||
})
|
||||
.from(schema.epgChannelMappings)
|
||||
.where(
|
||||
eq(schema.epgChannelMappings.channelKey, channelKey)
|
||||
)
|
||||
.limit(1);
|
||||
if (rows.length > 0) {
|
||||
return rows[0].epgChannelId;
|
||||
}
|
||||
|
||||
// Fallback: the key may be an Xtream provider epg_channel_id
|
||||
// while the mapping was saved under the playlist-scoped Xtream
|
||||
// key. Resolve the owning streams (joined through categories for
|
||||
// the playlist ID) and look up their mapping keys in a single
|
||||
// join, so a mapping saved under any matching stream is found —
|
||||
// an earlier "check the first few" cap could silently miss a
|
||||
// mapping saved under a later stream sharing this epg_channel_id.
|
||||
//
|
||||
// The join key is built in SQL to mirror
|
||||
// buildXtreamEpgMappingKey(playlistId, xtreamId) →
|
||||
// `xtream:{playlistId}:{xtreamId}`; keep the two in sync.
|
||||
//
|
||||
// This layer only receives the provider epg_channel_id, not the
|
||||
// caller's playlist, so when the same id exists in several
|
||||
// playlists it cannot scope to the caller's own mapping — the
|
||||
// renderer resolves the playlist-scoped key directly and this is
|
||||
// a best-effort fallback. Order deterministically so the chosen
|
||||
// mapping is at least stable rather than storage-order dependent.
|
||||
const mapped = await db
|
||||
.select({
|
||||
epgChannelId: schema.epgChannelMappings.epgChannelId,
|
||||
})
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.epgChannelMappings,
|
||||
eq(
|
||||
schema.epgChannelMappings.channelKey,
|
||||
sql`'xtream:' || ${schema.categories.playlistId} || ':' || ${schema.content.xtreamId}`
|
||||
)
|
||||
)
|
||||
.where(eq(schema.content.epgChannelId, channelKey))
|
||||
.orderBy(
|
||||
schema.categories.playlistId,
|
||||
schema.content.xtreamId
|
||||
)
|
||||
.limit(1);
|
||||
if (mapped.length > 0) {
|
||||
return mapped[0].epgChannelId;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const epgQueryService = new EpgQueryService();
|
||||
@@ -371,46 +371,47 @@ describe('EpgEvents', () => {
|
||||
|
||||
it('falls back to case-insensitive channel id lookup for EPG programs', async () => {
|
||||
const select = jest.fn();
|
||||
const programLimitExact = jest.fn().mockResolvedValue([]);
|
||||
const channelLimit = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]);
|
||||
const programLimitResolved = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'BBC.ONE.UK',
|
||||
start: '2026-04-14T10:00:00Z',
|
||||
stop: '2026-04-14T11:00:00Z',
|
||||
title: 'News',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build a flexible query-chain mock that every db.select().from().<method>() call
|
||||
* resolves to the same chain object. Each method (where, orderBy, limit) returns
|
||||
* the chain itself, and limit() additionally resolves to the given data. This
|
||||
* handles any query shape our service uses (with or without orderBy) without
|
||||
* requiring exact call-count ordering — important because our mapping feature
|
||||
* inserts extra queries that the original test didn't anticipate.
|
||||
*/
|
||||
function queryChain<T>(data: T) {
|
||||
const chain: Record<string, jest.Mock> = {} as Record<string, jest.Mock>;
|
||||
chain.where = jest.fn().mockReturnValue(chain);
|
||||
chain.innerJoin = jest.fn().mockReturnValue(chain);
|
||||
chain.groupBy = jest.fn().mockReturnValue(chain);
|
||||
chain.orderBy = jest.fn().mockReturnValue(chain);
|
||||
chain.limit = jest.fn().mockResolvedValue(data);
|
||||
return chain;
|
||||
}
|
||||
|
||||
// getMapping queries (must come first — they return empty)
|
||||
const from = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: programLimitExact,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: channelLimit,
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: programLimitResolved,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
|
||||
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
|
||||
// Test expectations below
|
||||
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
|
||||
.mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found
|
||||
.mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs)
|
||||
{
|
||||
id: 1,
|
||||
channelId: 'BBC.ONE.UK',
|
||||
start: '2026-04-14T10:00:00Z',
|
||||
stop: '2026-04-14T11:00:00Z',
|
||||
title: 'News',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
},
|
||||
]));
|
||||
|
||||
select.mockImplementation(() => ({ from }));
|
||||
|
||||
@@ -470,12 +471,14 @@ describe('EpgEvents', () => {
|
||||
const select = jest.fn();
|
||||
const from = jest.fn();
|
||||
const where = jest.fn();
|
||||
const groupBy = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const limit = jest.fn();
|
||||
|
||||
select.mockImplementation(() => ({ from }));
|
||||
from.mockReturnValue({ where });
|
||||
where.mockReturnValue({ orderBy });
|
||||
where.mockReturnValue({ groupBy });
|
||||
groupBy.mockReturnValue({ orderBy });
|
||||
orderBy.mockReturnValue({ limit });
|
||||
limit.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -9,6 +9,13 @@ import { getDatabase } from '../database/connection';
|
||||
import * as schema from '../database/schema';
|
||||
import { epgQueryService } from './epg-query.service';
|
||||
import { epgWorkerService } from './epg-worker.service';
|
||||
import {
|
||||
getEpgMapping,
|
||||
getEpgMappingsBatch,
|
||||
setEpgMapping,
|
||||
deleteEpgMapping,
|
||||
searchEpgChannels,
|
||||
} from '../database/operations/epg-mapping.operations';
|
||||
|
||||
/**
|
||||
* EPG Events Handler
|
||||
@@ -129,6 +136,59 @@ export default class EpgEvents {
|
||||
}
|
||||
);
|
||||
|
||||
// EPG channel mapping CRUD
|
||||
ipcMain.handle(
|
||||
'EPG_MAPPING_GET',
|
||||
async (_event, args: { channelKey: string }) => {
|
||||
return this.handleGetEpgMapping(args.channelKey);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_MAPPING_SET',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
channelKey: string;
|
||||
epgChannelId: string;
|
||||
playlistId?: string;
|
||||
}
|
||||
) => {
|
||||
return this.handleSetEpgMapping(
|
||||
args.channelKey,
|
||||
args.epgChannelId,
|
||||
args.playlistId
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_MAPPING_GET_BATCH',
|
||||
async (_event, args: { channelKeys: string[] }) => {
|
||||
return this.handleGetEpgMappingsBatch(args.channelKeys);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_MAPPING_DELETE',
|
||||
async (_event, args: { channelKey: string }) => {
|
||||
return this.handleDeleteEpgMapping(args.channelKey);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_CHANNEL_SEARCH',
|
||||
async (
|
||||
_event,
|
||||
args: { searchTerm: string; limit?: number }
|
||||
) => {
|
||||
return this.handleSearchEpgChannels(
|
||||
args.searchTerm,
|
||||
args.limit
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return ipcMain;
|
||||
}
|
||||
|
||||
@@ -294,7 +354,21 @@ export default class EpgEvents {
|
||||
channelIds: string[],
|
||||
options?: { sourceUrls?: string[] }
|
||||
): Promise<Record<string, EpgProgram | null>> {
|
||||
return epgQueryService.getCurrentProgramsBatch(channelIds, options);
|
||||
const resolvedMap = await this.resolveChannelIds(channelIds);
|
||||
const resolvedIds = channelIds.map(
|
||||
(id) => resolvedMap.get(id) ?? id
|
||||
);
|
||||
const results = await epgQueryService.getCurrentProgramsBatch(
|
||||
resolvedIds,
|
||||
options
|
||||
);
|
||||
// Remap results back to the original request keys.
|
||||
const remapped: Record<string, EpgProgram | null> = {};
|
||||
for (const originalId of channelIds) {
|
||||
const resolvedId = resolvedMap.get(originalId) ?? originalId;
|
||||
remapped[originalId] = results[resolvedId] ?? null;
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
private static async handleGetAllChannels(): Promise<{
|
||||
@@ -308,7 +382,21 @@ export default class EpgEvents {
|
||||
channelIds: string[],
|
||||
options?: { sourceUrls?: string[] }
|
||||
): Promise<Record<string, EpgChannelMetadata | null>> {
|
||||
return epgQueryService.getChannelMetadata(channelIds, options);
|
||||
const resolvedMap = await this.resolveChannelIds(channelIds);
|
||||
const resolvedIds = channelIds.map(
|
||||
(id) => resolvedMap.get(id) ?? id
|
||||
);
|
||||
const results = await epgQueryService.getChannelMetadata(
|
||||
resolvedIds,
|
||||
options
|
||||
);
|
||||
// Remap results back to the original request keys.
|
||||
const remapped: Record<string, EpgChannelMetadata | null> = {};
|
||||
for (const originalId of channelIds) {
|
||||
const resolvedId = resolvedMap.get(originalId) ?? originalId;
|
||||
remapped[originalId] = results[resolvedId] ?? null;
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
private static async handleGetChannelsByRange(
|
||||
@@ -325,6 +413,97 @@ export default class EpgEvents {
|
||||
return epgQueryService.getChannelsByRange(skip, limit);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EPG mapping resolution — called at the IPC boundary before queries.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Batch-resolve multiple channel IDs through manual mappings.
|
||||
* Returns a Map of original ID → mapped ID (identity when no mapping).
|
||||
*/
|
||||
private static async resolveChannelIds(
|
||||
channelIds: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const mappings = await getEpgMappingsBatch(db, channelIds);
|
||||
return mappings;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EPG mapping CRUD handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static async handleGetEpgMapping(
|
||||
channelKey: string
|
||||
): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
return getEpgMapping(db, channelKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async handleGetEpgMappingsBatch(
|
||||
channelKeys: string[]
|
||||
): Promise<Record<string, string>> {
|
||||
if (!Array.isArray(channelKeys) || channelKeys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const mappings = await getEpgMappingsBatch(db, channelKeys);
|
||||
return Object.fromEntries(mappings);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private static async handleSetEpgMapping(
|
||||
channelKey: string,
|
||||
epgChannelId: string,
|
||||
playlistId?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
return setEpgMapping(db, channelKey, epgChannelId, playlistId);
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
private static async handleDeleteEpgMapping(
|
||||
channelKey: string
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
return deleteEpgMapping(db, channelKey);
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
private static async handleSearchEpgChannels(
|
||||
searchTerm: string,
|
||||
limit?: number
|
||||
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
|
||||
if (!searchTerm?.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
return searchEpgChannels(db, searchTerm, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async clearEpgData(): Promise<void> {
|
||||
return epgWorkerService.clearEpgData();
|
||||
}
|
||||
|
||||
@@ -87,4 +87,37 @@ describe('sanitizePortalDebugEvent', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts credentials in request, response, errors, and URL query params', () => {
|
||||
const secrets = {
|
||||
username: 'main-user-secret',
|
||||
password: 'main-password-secret',
|
||||
token: 'main-token-secret',
|
||||
authorization: 'main-authorization-secret',
|
||||
mac: 'main-mac-secret',
|
||||
};
|
||||
const event = sanitizePortalDebugEvent({
|
||||
requestId: 'req-redaction',
|
||||
provider: 'stalker',
|
||||
operation: 'get_profile',
|
||||
transport: 'electron-main',
|
||||
startedAt: new Date().toISOString(),
|
||||
durationMs: 5,
|
||||
status: 'error',
|
||||
request: {
|
||||
params: secrets,
|
||||
url: `https://example.com/portal?token=${secrets.token}&action=get_profile`,
|
||||
},
|
||||
error: new Error(
|
||||
`Request failed: https://example.com/portal?authorization=${secrets.authorization}&action=get_profile`
|
||||
),
|
||||
});
|
||||
|
||||
const output = JSON.stringify(event);
|
||||
for (const secret of Object.values(secrets)) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('get_profile');
|
||||
expect(output).toContain('req-redaction');
|
||||
});
|
||||
});
|
||||
@@ -1,117 +1,19 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { PORTAL_DEBUG_EVENT, PortalDebugEvent } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_DEBUG_EVENT,
|
||||
PortalDebugEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
const MAX_DEBUG_DEPTH = 6;
|
||||
|
||||
function sanitizePortalDebugValue(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
depth = 0
|
||||
): unknown {
|
||||
if (
|
||||
value == null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (depth >= MAX_DEBUG_DEPTH) {
|
||||
return '[MaxDepth]';
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
const baseError = {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
for (const [key, entry] of Object.entries(
|
||||
value as unknown as Record<string, unknown>
|
||||
)) {
|
||||
baseError[key] = sanitizePortalDebugValue(
|
||||
entry,
|
||||
seen,
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
|
||||
return baseError;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (value instanceof URL) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) =>
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
return Object.fromEntries(
|
||||
[...value.entries()].map(([key, entry]) => [
|
||||
String(key),
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
return [...value].map((entry) =>
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>).map(
|
||||
([key, entry]) => [
|
||||
key,
|
||||
sanitizePortalDebugValue(entry, seen, depth + 1),
|
||||
]
|
||||
);
|
||||
|
||||
seen.delete(value);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function sanitizePortalDebugEvent(
|
||||
event: PortalDebugEvent
|
||||
): PortalDebugEvent {
|
||||
return {
|
||||
...event,
|
||||
request: sanitizePortalDebugValue(event.request),
|
||||
response: sanitizePortalDebugValue(event.response),
|
||||
error: sanitizePortalDebugValue(event.error),
|
||||
request: redactSensitiveData(event.request),
|
||||
response: redactSensitiveData(event.response),
|
||||
error: redactSensitiveData(event.error),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(
|
||||
(channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler);
|
||||
}
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments',
|
||||
MPV_REUSE_INSTANCE: 'mpvReuseInstance',
|
||||
VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments',
|
||||
VLC_REUSE_INSTANCE: 'vlcReuseInstance',
|
||||
store: { get: jest.fn(), set: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../server/http-server', () => ({
|
||||
httpServer: { updateSettings: jest.fn() },
|
||||
}));
|
||||
|
||||
describe('SETTINGS_UPDATE logging', () => {
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
await import('./settings.events');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not print a TMDB apiKey while retaining useful fields', () => {
|
||||
const apiKey = 'tmdb-settings-api-key-secret';
|
||||
const handler = handlers.get('SETTINGS_UPDATE');
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } });
|
||||
|
||||
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
|
||||
expect(output).not.toContain(apiKey);
|
||||
expect(output).toContain('language');
|
||||
expect(output).toContain('de');
|
||||
expect(output).toContain('enabled');
|
||||
});
|
||||
|
||||
it('does not print credentials embedded in external player arguments', () => {
|
||||
const authorizationSecret = 'player-authorization-secret';
|
||||
const cookieSecret = 'player-cookie-secret';
|
||||
const handler = handlers.get('SETTINGS_UPDATE');
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
handler?.(
|
||||
{},
|
||||
{
|
||||
language: 'de',
|
||||
mpvPlayerArguments: `--http-header-fields=Authorization: Bearer ${authorizationSecret}`,
|
||||
vlcPlayerArguments: `--http-referrer=https://example.com --http-cookie=Cookie: ${cookieSecret}`,
|
||||
}
|
||||
);
|
||||
|
||||
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
|
||||
expect(output).not.toContain(authorizationSecret);
|
||||
expect(output).not.toContain(cookieSecret);
|
||||
expect(output).toContain('language');
|
||||
expect(output).toContain('de');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS,
|
||||
@@ -17,7 +18,10 @@ export default class SettingsEvents {
|
||||
}
|
||||
|
||||
ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
console.log('Received SETTINGS_UPDATE with data:', arg);
|
||||
console.log(
|
||||
'Received SETTINGS_UPDATE with data:',
|
||||
redactSensitiveData(arg)
|
||||
);
|
||||
|
||||
if (arg.mpvPlayerArguments !== undefined) {
|
||||
store.set(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PortalDebugEvent,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
import { buildStalkerIdentityRequestContext } from './stalker-identity';
|
||||
@@ -186,7 +187,10 @@ ipcMain.handle(
|
||||
emitPortalDebugEvent(debugEvent);
|
||||
}
|
||||
|
||||
console.error('[StalkerEvents] Request error:', error);
|
||||
console.error(
|
||||
'[StalkerEvents] Request error:',
|
||||
redactSensitiveData(error)
|
||||
);
|
||||
|
||||
// Format error response
|
||||
if (axios.isAxiosError(error)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XTREAM_CANCEL_SESSION,
|
||||
normalizeXtreamServerUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
import { UnsafeUrlError } from './url-safety';
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
@@ -212,10 +213,12 @@ ipcMain.handle(
|
||||
if (!payload.suppressErrorLog) {
|
||||
console.error(
|
||||
'[XTREAM_REQUEST] Failed',
|
||||
formatXtreamError(
|
||||
error,
|
||||
requestUrlForLog,
|
||||
payload.params?.action
|
||||
redactSensitiveData(
|
||||
formatXtreamError(
|
||||
error,
|
||||
requestUrlForLog,
|
||||
payload.params?.action
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { trace } from './debug-trace';
|
||||
|
||||
describe('debug trace redaction', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not serialize credentials from nested payloads or URLs', () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const secrets = {
|
||||
password: 'trace-password-secret',
|
||||
token: 'trace-token-secret',
|
||||
authorization: 'trace-authorization-secret',
|
||||
mac: 'trace-mac-secret',
|
||||
};
|
||||
|
||||
trace('portal', 'request', {
|
||||
params: secrets,
|
||||
url: `https://example.com/portal?token=${secrets.token}&action=get_profile`,
|
||||
requestId: 'diagnostic-request-id',
|
||||
});
|
||||
|
||||
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
|
||||
for (const secret of Object.values(secrets)) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('diagnostic-request-id');
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
it('redacts serialized credentials before truncating trace strings', () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const secret = 'long-trace-json-password-secret';
|
||||
const diagnostic = JSON.stringify({
|
||||
password: secret,
|
||||
operation: 'get_profile',
|
||||
padding: 'x'.repeat(300),
|
||||
});
|
||||
|
||||
trace('portal', 'request', { diagnostic });
|
||||
|
||||
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
|
||||
expect(output).not.toContain(secret);
|
||||
expect(output).toContain('[Redacted]');
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
|
||||
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const TRACE_PREFIX = '[IPTVnator Trace]';
|
||||
const MAX_TRACE_ARRAY_ITEMS = 5;
|
||||
@@ -146,10 +148,10 @@ export function summarizeForTrace(value: unknown, depth = 0): unknown {
|
||||
|
||||
export function safeStringifyForTrace(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
return JSON.stringify(redactSensitiveData(payload));
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
fallback: summarizeForTrace(payload),
|
||||
fallback: summarizeForTrace(redactSensitiveData(payload)),
|
||||
stringifyError:
|
||||
error instanceof Error
|
||||
? truncateString(error.message)
|
||||
@@ -166,7 +168,7 @@ export function trace(scope: string, message: string, payload?: unknown): void {
|
||||
|
||||
console.log(
|
||||
`${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace(
|
||||
summarizeForTrace(payload)
|
||||
summarizeForTrace(redactSensitiveData(payload))
|
||||
)}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
NativeViewBoundsContext,
|
||||
toNativeViewBounds,
|
||||
} from './embedded-mpv-bounds.util';
|
||||
|
||||
const CSS_BOUNDS: EmbeddedMpvBounds = { x: 372, y: 60, width: 578, height: 330 };
|
||||
|
||||
function context(
|
||||
overrides: Partial<NativeViewBoundsContext> = {}
|
||||
): NativeViewBoundsContext {
|
||||
return {
|
||||
platform: 'linux',
|
||||
zoomFactor: 1,
|
||||
displayScaleFactor: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('toNativeViewBounds', () => {
|
||||
it('returns the input untouched at 100% zoom and 100% display scale', () => {
|
||||
const result = toNativeViewBounds(CSS_BOUNDS, context());
|
||||
|
||||
expect(result).toBe(CSS_BOUNDS);
|
||||
});
|
||||
|
||||
// Regression for #1145: CSS bounds were handed to XMoveResizeWindow as-is,
|
||||
// so on a 140%-scaled Linux Mint desktop the mpv window landed at ~71% of
|
||||
// the expected position and size, toward the window's top-left corner.
|
||||
it('scales linux bounds by the display scale factor', () => {
|
||||
const result = toNativeViewBounds(
|
||||
CSS_BOUNDS,
|
||||
context({ platform: 'linux', displayScaleFactor: 1.4 })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 521, y: 84, width: 809, height: 462 });
|
||||
});
|
||||
|
||||
it('scales win32 bounds by the display scale factor', () => {
|
||||
const result = toNativeViewBounds(
|
||||
{ x: 100, y: 50, width: 640, height: 360 },
|
||||
context({ platform: 'win32', displayScaleFactor: 1.25 })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 125, y: 63, width: 800, height: 450 });
|
||||
});
|
||||
|
||||
it('combines page zoom with the display scale factor', () => {
|
||||
const result = toNativeViewBounds(
|
||||
{ x: 100, y: 50, width: 640, height: 360 },
|
||||
context({
|
||||
platform: 'win32',
|
||||
zoomFactor: 1.2,
|
||||
displayScaleFactor: 1.5,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 180, y: 90, width: 1152, height: 648 });
|
||||
});
|
||||
|
||||
it('ignores the display scale on macOS (NSView frames are in points)', () => {
|
||||
const result = toNativeViewBounds(
|
||||
CSS_BOUNDS,
|
||||
context({ platform: 'darwin', displayScaleFactor: 2 })
|
||||
);
|
||||
|
||||
expect(result).toBe(CSS_BOUNDS);
|
||||
});
|
||||
|
||||
it('applies page zoom on macOS', () => {
|
||||
const result = toNativeViewBounds(
|
||||
{ x: 100, y: 50, width: 640, height: 360 },
|
||||
context({
|
||||
platform: 'darwin',
|
||||
zoomFactor: 1.5,
|
||||
displayScaleFactor: 2,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 150, y: 75, width: 960, height: 540 });
|
||||
});
|
||||
|
||||
it('rounds fractional CSS edges only after scaling', () => {
|
||||
// A 10.49px CSS edge at 200% renders at 21 physical pixels; edges
|
||||
// rounded before scaling would send 20 and shift the video by 1px.
|
||||
const result = toNativeViewBounds(
|
||||
{ x: 10.49, y: 0.5, width: 100.02, height: 50 },
|
||||
context({ platform: 'win32', displayScaleFactor: 2 })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ x: 21, y: 1, width: 200, height: 100 });
|
||||
});
|
||||
|
||||
it('keeps vertically adjacent rects seamless under fractional scales', () => {
|
||||
// 42 × 1.25 and 153 × 1.25 both land on .5/.25 fractions: rounding
|
||||
// x/y/width/height independently would misplace the shared edge by
|
||||
// 1px, while edge-based rounding keeps the rects flush.
|
||||
const scale = context({ displayScaleFactor: 1.25 });
|
||||
const upper = toNativeViewBounds(
|
||||
{ x: 0, y: 42, width: 500, height: 111 },
|
||||
scale
|
||||
);
|
||||
const lower = toNativeViewBounds(
|
||||
{ x: 0, y: 153, width: 500, height: 90 },
|
||||
scale
|
||||
);
|
||||
|
||||
expect(upper.y + upper.height).toBe(lower.y);
|
||||
});
|
||||
|
||||
it('keeps hidden bounds offscreen and at least 1x1', () => {
|
||||
const result = toNativeViewBounds(
|
||||
{ x: -100000, y: -100000, width: 1, height: 1 },
|
||||
context({ displayScaleFactor: 1.5 })
|
||||
);
|
||||
|
||||
expect(result.x).toBeLessThanOrEqual(-100000);
|
||||
expect(result.y).toBeLessThanOrEqual(-100000);
|
||||
expect(result.width).toBeGreaterThanOrEqual(1);
|
||||
expect(result.height).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('treats non-finite or non-positive factors as 100%', () => {
|
||||
for (const zoomFactor of [Number.NaN, 0, -1, Number.POSITIVE_INFINITY]) {
|
||||
expect(
|
||||
toNativeViewBounds(
|
||||
CSS_BOUNDS,
|
||||
context({ zoomFactor, displayScaleFactor: zoomFactor })
|
||||
)
|
||||
).toBe(CSS_BOUNDS);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface NativeViewBoundsContext {
|
||||
platform: NodeJS.Platform;
|
||||
/** Page zoom factor of the main window's webContents (1 = 100%). */
|
||||
zoomFactor: number;
|
||||
/** Scale factor of the display hosting the main window (1 = 96 dpi). */
|
||||
displayScaleFactor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts renderer-measured bounds (CSS pixels from
|
||||
* getBoundingClientRect()) into the coordinate space the native-view
|
||||
* engines position their OS windows in: physical pixels for the win32
|
||||
* child HWND (SetWindowPos) and the linux child X11 window
|
||||
* (XMoveResizeWindow), points — device-independent pixels — for the macOS
|
||||
* NSView (setFrame). CSS pixels match points only at 100% page zoom and
|
||||
* match physical pixels only at 100% page zoom AND 100% display scale, so
|
||||
* every platform scales by the zoom factor and win32/linux additionally by
|
||||
* the display scale factor (#1145).
|
||||
*
|
||||
* Bounds arrive with unrounded CSS edges and are rounded exactly once here,
|
||||
* after scaling: edges first, then width/height derived from them. Rounding
|
||||
* any earlier (or per-field) lets fractional CSS layouts drift by a pixel
|
||||
* per scale factor and open 1px seams between the native video window and
|
||||
* the surrounding DOM UI.
|
||||
*/
|
||||
export function toNativeViewBounds(
|
||||
bounds: EmbeddedMpvBounds,
|
||||
context: NativeViewBoundsContext
|
||||
): EmbeddedMpvBounds {
|
||||
const scale = resolveNativeViewScale(context);
|
||||
if (scale === 1) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
const left = Math.round(bounds.x * scale);
|
||||
const top = Math.round(bounds.y * scale);
|
||||
const right = Math.round((bounds.x + bounds.width) * scale);
|
||||
const bottom = Math.round((bounds.y + bounds.height) * scale);
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: Math.max(1, right - left),
|
||||
height: Math.max(1, bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNativeViewScale(context: NativeViewBoundsContext): number {
|
||||
const zoomFactor = sanitizeFactor(context.zoomFactor);
|
||||
if (context.platform === 'darwin') {
|
||||
return zoomFactor;
|
||||
}
|
||||
return zoomFactor * sanitizeFactor(context.displayScaleFactor);
|
||||
}
|
||||
|
||||
function sanitizeFactor(value: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
+187
-20
@@ -10,10 +10,17 @@ const mockElectronApp = {
|
||||
jest.mock('electron', () => ({ app: mockElectronApp }));
|
||||
|
||||
import {
|
||||
getEmbeddedMpvAddonCandidatePaths,
|
||||
getFrameCopyRuntimeAvailability,
|
||||
isFrameCopyPlatformSupported,
|
||||
isFrameCopyRuntimeUsable,
|
||||
resolveFrameCopyHelperPath,
|
||||
shouldPromotePersistedFrameCopyOptIn,
|
||||
} from './embedded-mpv-frame-copy-platform.util';
|
||||
import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
} from './embedded-mpv-frame-copy-runtime';
|
||||
|
||||
describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
describe('isFrameCopyPlatformSupported', () => {
|
||||
@@ -31,7 +38,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
['darwin', 'arm64', true],
|
||||
['darwin', 'x64', false],
|
||||
['linux', 'x64', true],
|
||||
['linux', 'arm64', true],
|
||||
['linux', 'arm64', false],
|
||||
['linux', 'arm', false],
|
||||
['win32', 'x64', true],
|
||||
['freebsd', 'x64', false],
|
||||
])('%s/%s -> %s', (platform, arch, expected) => {
|
||||
@@ -61,8 +69,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
process.platform === 'win32'
|
||||
? 'iptvnator_mpv_helper.exe'
|
||||
: 'iptvnator_mpv_helper';
|
||||
const helperPath = () =>
|
||||
path.join(releaseDir(), helperFileName());
|
||||
const helperPath = () => path.join(releaseDir(), helperFileName());
|
||||
const readerPath = () =>
|
||||
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
|
||||
|
||||
@@ -152,24 +159,184 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
|
||||
expect(resolveFrameCopyHelperPath()).toBe(packagedHelper);
|
||||
});
|
||||
|
||||
it('limits packaged native-view addon discovery to package-owned paths', () => {
|
||||
mockElectronApp.isPackaged = true;
|
||||
const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources');
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
configurable: true,
|
||||
value: resourcesPath,
|
||||
});
|
||||
mockElectronApp.getAppPath.mockReturnValue(
|
||||
path.join(resourcesPath, 'app.asar')
|
||||
);
|
||||
|
||||
expect(getEmbeddedMpvAddonCandidatePaths()).toEqual([
|
||||
path.join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native',
|
||||
'embedded_mpv.node'
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => {
|
||||
const shouldPromote = (
|
||||
frameCopyPlatform as typeof frameCopyPlatform & {
|
||||
shouldPromotePersistedFrameCopyOptIn?: (
|
||||
storedEnabled: boolean,
|
||||
explicitEnv: string | undefined,
|
||||
runtimeUsable: boolean
|
||||
) => boolean;
|
||||
}
|
||||
).shouldPromotePersistedFrameCopyOptIn;
|
||||
describe('isFrameCopyRuntimeUsable', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
|
||||
expect(shouldPromote).toBeDefined();
|
||||
expect(shouldPromote?.(true, undefined, true)).toBe(true);
|
||||
expect(shouldPromote?.(true, undefined, false)).toBe(false);
|
||||
expect(shouldPromote?.(true, '0', true)).toBe(false);
|
||||
expect(shouldPromote?.(true, '1', true)).toBe(false);
|
||||
expect(shouldPromote?.(false, undefined, true)).toBe(false);
|
||||
beforeEach(() => {
|
||||
mockElectronApp.isPackaged = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||
mockElectronApp.isPackaged = false;
|
||||
});
|
||||
|
||||
it('requires a successful Linux x64 runtime probe', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
|
||||
const probeRuntime = jest.fn<
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
[string, EmbeddedMpvFrameCopyManifestContract]
|
||||
>(() => ({
|
||||
usable: true,
|
||||
profile: 'system',
|
||||
runtimeMode: 'system',
|
||||
libmpv: '2.3',
|
||||
renderApi: 'egl',
|
||||
}));
|
||||
|
||||
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
|
||||
true
|
||||
);
|
||||
expect(probeRuntime).toHaveBeenCalledWith(
|
||||
'/native/iptvnator_mpv_helper',
|
||||
'development'
|
||||
);
|
||||
|
||||
probeRuntime.mockReturnValueOnce({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
});
|
||||
expect(
|
||||
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('selects the packaged manifest contract only from app.isPackaged', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
mockElectronApp.isPackaged = true;
|
||||
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
|
||||
const probeRuntime = jest.fn<
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
[string, EmbeddedMpvFrameCopyManifestContract]
|
||||
>(() => ({
|
||||
usable: true,
|
||||
profile: 'system',
|
||||
runtimeMode: 'system',
|
||||
libmpv: '2.3',
|
||||
renderApi: 'egl',
|
||||
}));
|
||||
|
||||
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
|
||||
true
|
||||
);
|
||||
expect(probeRuntime).toHaveBeenCalledWith(
|
||||
'/native/iptvnator_mpv_helper',
|
||||
'packaged'
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[NodeJS.Platform, string]>([
|
||||
['darwin', 'arm64'],
|
||||
['win32', 'x64'],
|
||||
])(
|
||||
'keeps the existing helper-presence gate on %s',
|
||||
(platform, arch) => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
const resolveHelper = jest.fn(
|
||||
() => '/native/iptvnator_mpv_helper'
|
||||
);
|
||||
const probeRuntime = jest.fn();
|
||||
|
||||
expect(
|
||||
isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)
|
||||
).toBe(true);
|
||||
expect(probeRuntime).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects Linux ARM before helper discovery or probing', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
||||
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
|
||||
const probeRuntime = jest.fn();
|
||||
|
||||
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
|
||||
false
|
||||
);
|
||||
expect(resolveHelper).not.toHaveBeenCalled();
|
||||
expect(probeRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unsupported architecture for Intel macOS', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
|
||||
const probeRuntime = jest.fn();
|
||||
|
||||
expect(
|
||||
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'unsupported-architecture',
|
||||
});
|
||||
expect(resolveHelper).not.toHaveBeenCalled();
|
||||
expect(probeRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('lazily probes only a stored opt-in without an explicit env override', () => {
|
||||
const runtimeUsable = jest.fn(() => true);
|
||||
expect(
|
||||
shouldPromotePersistedFrameCopyOptIn(
|
||||
false,
|
||||
undefined,
|
||||
runtimeUsable
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPromotePersistedFrameCopyOptIn(true, '0', runtimeUsable)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPromotePersistedFrameCopyOptIn(true, '1', runtimeUsable)
|
||||
).toBe(false);
|
||||
expect(runtimeUsable).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable)
|
||||
).toBe(true);
|
||||
expect(runtimeUsable).toHaveBeenCalledTimes(1);
|
||||
|
||||
runtimeUsable.mockReturnValue(false);
|
||||
expect(
|
||||
shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable)
|
||||
).toBe(false);
|
||||
expect(runtimeUsable).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { app } from 'electron';
|
||||
import { accessSync, constants as fsConstants, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
} from './embedded-mpv-frame-copy-runtime';
|
||||
|
||||
/**
|
||||
* Platform gate + helper discovery for the embedded MPV frame-copy engine,
|
||||
@@ -9,15 +14,15 @@ import path from 'path';
|
||||
* callable before app.whenReady().
|
||||
*
|
||||
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
|
||||
* the docked native engine. Linux: any arch — the helper renders offscreen
|
||||
* through headless EGL and links libmpv out of process, so neither window
|
||||
* embedding nor the in-process-libmpv ban constrains it. Windows: any arch
|
||||
* with a helper binary (WGL offscreen render; in practice x64, the only
|
||||
* vendored runtime) — the helper-presence check below is the real gate.
|
||||
* the docked native engine. Linux: x64 only — official packages validate a
|
||||
* profile manifest and run the helper's bounded EGL/libmpv capability probe;
|
||||
* ARM packages remain honestly unavailable. Windows: any arch with a helper
|
||||
* binary (WGL offscreen render; in practice x64, the only vendored runtime)
|
||||
* — the helper-presence check below is the real gate.
|
||||
*/
|
||||
export function isFrameCopyPlatformSupported(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' ||
|
||||
(process.platform === 'linux' && process.arch === 'x64') ||
|
||||
process.platform === 'win32' ||
|
||||
(process.platform === 'darwin' && process.arch === 'arm64')
|
||||
);
|
||||
@@ -77,7 +82,7 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] {
|
||||
|
||||
return dedupeDefinedPaths(
|
||||
app.isPackaged
|
||||
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
|
||||
? packagedAddonPaths
|
||||
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
|
||||
);
|
||||
}
|
||||
@@ -104,10 +109,7 @@ export function resolveFrameCopyHelperPath(): string | null {
|
||||
.map((candidatePath) => path.dirname(candidatePath))
|
||||
.map((nativeDir) => ({
|
||||
helper: path.join(nativeDir, helperFileName),
|
||||
reader: path.join(
|
||||
nativeDir,
|
||||
'embedded_mpv_frame_reader.node'
|
||||
),
|
||||
reader: path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
}))
|
||||
.find(({ helper, reader }) => {
|
||||
try {
|
||||
@@ -127,16 +129,80 @@ export function resolveFrameCopyHelperPath(): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
type FrameCopyRuntimeProbe = (
|
||||
helperPath: string,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract
|
||||
) => EmbeddedMpvFrameCopyRuntimeResult;
|
||||
|
||||
export type FrameCopyRuntimeAvailability =
|
||||
| EmbeddedMpvFrameCopyRuntimeResult
|
||||
| { usable: true };
|
||||
|
||||
let cachedDefaultRuntimeAvailability: FrameCopyRuntimeAvailability | undefined;
|
||||
|
||||
export function getFrameCopyRuntimeAvailability(
|
||||
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
|
||||
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
|
||||
): FrameCopyRuntimeAvailability {
|
||||
const usesProcessDecision =
|
||||
resolveHelper === resolveFrameCopyHelperPath &&
|
||||
probeRuntime === probeEmbeddedMpvFrameCopyRuntime;
|
||||
if (usesProcessDecision && cachedDefaultRuntimeAvailability !== undefined) {
|
||||
return cachedDefaultRuntimeAvailability;
|
||||
}
|
||||
|
||||
let availability: FrameCopyRuntimeAvailability;
|
||||
if (!isFrameCopyPlatformSupported()) {
|
||||
availability = {
|
||||
usable: false,
|
||||
reason:
|
||||
process.platform === 'linux' || process.platform === 'darwin'
|
||||
? 'unsupported-architecture'
|
||||
: 'unsupported-platform',
|
||||
};
|
||||
} else {
|
||||
const helperPath = resolveHelper();
|
||||
if (!helperPath) {
|
||||
availability = {
|
||||
usable: false,
|
||||
reason: 'runtime-artifact-missing',
|
||||
};
|
||||
} else if (process.platform !== 'linux') {
|
||||
availability = { usable: true };
|
||||
} else {
|
||||
try {
|
||||
// app.isPackaged is the trusted boundary. Environment flags
|
||||
// can request the feature, but cannot weaken its manifest
|
||||
// contract or make development artifacts package-trusted.
|
||||
availability = probeRuntime(
|
||||
helperPath,
|
||||
app.isPackaged ? 'packaged' : 'development'
|
||||
);
|
||||
} catch {
|
||||
availability = {
|
||||
usable: false,
|
||||
reason: 'runtime-probe-internal-error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (usesProcessDecision) {
|
||||
cachedDefaultRuntimeAvailability = availability;
|
||||
}
|
||||
return availability;
|
||||
}
|
||||
|
||||
export function isFrameCopyRuntimeUsable(
|
||||
resolveHelper: () => string | null = resolveFrameCopyHelperPath
|
||||
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
|
||||
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
|
||||
): boolean {
|
||||
return isFrameCopyPlatformSupported() && resolveHelper() !== null;
|
||||
return getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime).usable;
|
||||
}
|
||||
|
||||
export function shouldPromotePersistedFrameCopyOptIn(
|
||||
storedEnabled: boolean,
|
||||
explicitEnv: string | undefined,
|
||||
runtimeUsable = isFrameCopyRuntimeUsable()
|
||||
runtimeUsable: () => boolean = isFrameCopyRuntimeUsable
|
||||
): boolean {
|
||||
return explicitEnv === undefined && storedEnabled && runtimeUsable;
|
||||
return explicitEnv === undefined && storedEnabled && runtimeUsable();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment';
|
||||
export { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime/helper-launch';
|
||||
export {
|
||||
createEmbeddedMpvFrameCopyRuntimeProbe,
|
||||
probeEmbeddedMpvFrameCopyRuntime,
|
||||
} from './embedded-mpv-frame-copy-runtime/probe';
|
||||
export type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeDependencies,
|
||||
EmbeddedMpvFrameCopyRuntimeFailureReason,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
EmbeddedMpvFrameCopyRuntimeMode,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
EmbeddedMpvHelperRuntimeProbeFailureReason,
|
||||
} from './embedded-mpv-frame-copy-runtime/types';
|
||||
export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch';
|
||||
@@ -0,0 +1,330 @@
|
||||
import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-contract.cjs');
|
||||
import sourceArchiveContract = require('../../../../../../tools/embedded-mpv/linux-source-archive-contract.cjs');
|
||||
import type { EmbeddedMpvFrameCopyRuntimeMode, RuntimeProfile } from './types';
|
||||
|
||||
interface RuntimeProfileContract {
|
||||
origin: string;
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
targets: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json';
|
||||
export const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node';
|
||||
export const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node';
|
||||
export const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper';
|
||||
export const RUNTIME_PROBE_PROTOCOL = 1;
|
||||
export const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS } =
|
||||
runtimeProbeContract;
|
||||
export const {
|
||||
SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION,
|
||||
SOURCE_ARCHIVE_NAME,
|
||||
validateLinuxSourceArchiveBinding,
|
||||
} = sourceArchiveContract;
|
||||
export const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
|
||||
export const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/;
|
||||
export const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/;
|
||||
export const PINNED_LIBPLACEBO_SOURCE_SUBMODULES = [
|
||||
'450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers',
|
||||
'97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float',
|
||||
'73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad',
|
||||
'15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja',
|
||||
'297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe',
|
||||
'242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear',
|
||||
] as const;
|
||||
export const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
export const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/;
|
||||
export const SUBMODULE_RECORD_PATTERN =
|
||||
/^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/;
|
||||
export const VERSION_PATTERN = /^\d+(?:\.\d+)+$/;
|
||||
|
||||
export const GLIBC_TOOLCHAIN_ALLOWLIST = [
|
||||
'ld-linux-x86-64.so.2',
|
||||
'libc.so.6',
|
||||
'libdl.so.2',
|
||||
'libgcc_s.so.1',
|
||||
'libm.so.6',
|
||||
'libpthread.so.0',
|
||||
'librt.so.1',
|
||||
'libstdc++.so.6',
|
||||
] as const;
|
||||
|
||||
export const EXPECTED_EXTERNAL_SYSTEM_LIBRARIES = [
|
||||
{
|
||||
name: 'libEGL.so.1',
|
||||
interface: 'EGL',
|
||||
reason: 'System graphics-driver interface used by the frame-copy helper.',
|
||||
},
|
||||
{
|
||||
name: 'libGL.so.1',
|
||||
interface: 'OpenGL',
|
||||
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libGLX.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libOpenGL.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libasound.so.2',
|
||||
interface: 'ALSA',
|
||||
reason: 'Linux system audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libdrm.so.2',
|
||||
interface: 'DRM',
|
||||
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
|
||||
},
|
||||
{
|
||||
name: 'libgbm.so.1',
|
||||
interface: 'GBM',
|
||||
reason: 'System graphics-buffer interface used by headless EGL rendering.',
|
||||
},
|
||||
{
|
||||
name: 'libpulse.so.0',
|
||||
interface: 'PulseAudio',
|
||||
reason: 'Linux desktop audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libva-drm.so.2',
|
||||
interface: 'VA-API DRM',
|
||||
reason: 'System VA-API DRM interface used for hardware decoding.',
|
||||
},
|
||||
{
|
||||
name: 'libva.so.2',
|
||||
interface: 'VA-API',
|
||||
reason: 'System video-acceleration interface used for hardware decoding.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set<string>([
|
||||
...GLIBC_TOOLCHAIN_ALLOWLIST,
|
||||
...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
|
||||
]);
|
||||
|
||||
export const PORTABLE_ABI_BASELINE = {
|
||||
distribution: 'Ubuntu 22.04',
|
||||
glibcMaximum: '2.35',
|
||||
glibcxxMaximum: '3.4.30',
|
||||
} as const;
|
||||
|
||||
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
|
||||
freetype: {
|
||||
version: '2.13.3',
|
||||
sourceUrl:
|
||||
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
|
||||
sourceSha256:
|
||||
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
|
||||
license: 'FreeType License (FTL)',
|
||||
},
|
||||
fribidi: {
|
||||
version: '1.0.16',
|
||||
sourceUrl:
|
||||
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
|
||||
sourceSha256:
|
||||
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
harfbuzz: {
|
||||
version: '8.5.0',
|
||||
sourceUrl:
|
||||
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
|
||||
sourceSha256:
|
||||
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
|
||||
license: 'MIT',
|
||||
},
|
||||
expat: {
|
||||
version: '2.8.2',
|
||||
sourceUrl:
|
||||
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
|
||||
sourceSha256:
|
||||
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
|
||||
license: 'MIT',
|
||||
},
|
||||
fontconfig: {
|
||||
version: '2.16.0',
|
||||
sourceUrl:
|
||||
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
|
||||
sourceSha256:
|
||||
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
|
||||
license: 'MIT',
|
||||
},
|
||||
libass: {
|
||||
version: '0.17.3',
|
||||
sourceUrl:
|
||||
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
|
||||
sourceSha256:
|
||||
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
|
||||
license: 'ISC',
|
||||
},
|
||||
openssl: {
|
||||
version: '3.5.7',
|
||||
sourceUrl:
|
||||
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
|
||||
sourceSha256:
|
||||
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
ffmpeg: {
|
||||
version: '8.1',
|
||||
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
|
||||
sourceSha256:
|
||||
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
libplacebo: {
|
||||
version: '7.360.1',
|
||||
sourceUrl: 'https://github.com/haasn/libplacebo.git',
|
||||
sourceTag: 'v7.360.1',
|
||||
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
|
||||
sourceSubmodules: PINNED_LIBPLACEBO_SOURCE_SUBMODULES,
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
hwdata: {
|
||||
version: '0.409',
|
||||
sourceUrl:
|
||||
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
|
||||
sourceSha256:
|
||||
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
|
||||
buildInput: {
|
||||
consumer: 'libdisplay-info',
|
||||
relativePath: 'pnp.ids',
|
||||
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
|
||||
},
|
||||
license: 'GPL-2.0-or-later OR XFree86-1.0',
|
||||
},
|
||||
'libdisplay-info': {
|
||||
version: '0.1.1',
|
||||
sourceUrl:
|
||||
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
|
||||
sourceSha256:
|
||||
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
|
||||
license: 'MIT',
|
||||
},
|
||||
mpv: {
|
||||
version: '0.41.0',
|
||||
sourceUrl:
|
||||
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
|
||||
sourceSha256:
|
||||
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
|
||||
license: 'LGPL-2.1-or-later with -Dgpl=false',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const EXPECTED_ARTIFACTS = {
|
||||
addon: {
|
||||
name: FRAME_COPY_ADDON_NAME,
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
frameReader: {
|
||||
name: FRAME_COPY_READER_NAME,
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
helper: {
|
||||
name: FRAME_COPY_HELPER_NAME,
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
executable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EXPECTED_PROCESS_ISOLATION = {
|
||||
addonLoadsLibmpv: false,
|
||||
readerLoadsLibmpv: false,
|
||||
electronLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
};
|
||||
|
||||
export const EXPECTED_DEVELOPMENT_ARTIFACTS = {
|
||||
addon: FRAME_COPY_ADDON_NAME,
|
||||
frameReader: FRAME_COPY_READER_NAME,
|
||||
helper: FRAME_COPY_HELPER_NAME,
|
||||
};
|
||||
|
||||
export const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = {
|
||||
addonLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
};
|
||||
|
||||
export const DEVELOPMENT_MANIFEST_FIELDS = [
|
||||
'allowedPackageRuntimeModes',
|
||||
'arch',
|
||||
'artifacts',
|
||||
'buildInputMode',
|
||||
'generatedAt',
|
||||
'libmpvSoname',
|
||||
'nativeViewFallback',
|
||||
'origin',
|
||||
'packageRuntimeAvailability',
|
||||
'platform',
|
||||
'processIsolation',
|
||||
'runtimeFiles',
|
||||
'runtimeTotalBytes',
|
||||
'schemaVersion',
|
||||
'sourceArchive',
|
||||
'sourceRuntime',
|
||||
'sourceRuntimeValidated',
|
||||
] as const;
|
||||
|
||||
export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({
|
||||
deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']),
|
||||
rpm: Object.freeze([
|
||||
'mpv-libs',
|
||||
'libglvnd-egl',
|
||||
'libglvnd-glx',
|
||||
'mesa-libgbm',
|
||||
]),
|
||||
pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']),
|
||||
});
|
||||
|
||||
export const PROFILE_CONTRACTS = {
|
||||
system: {
|
||||
origin: 'system-libmpv-frame-copy',
|
||||
runtimeMode: 'system',
|
||||
targets: new Set(['deb', 'rpm', 'pacman']),
|
||||
},
|
||||
portable: {
|
||||
origin: 'bundled-lgpl-frame-copy',
|
||||
runtimeMode: 'bundled',
|
||||
targets: new Set(['appimage', 'snap']),
|
||||
},
|
||||
flatpak: {
|
||||
origin: 'bundled-lgpl-frame-copy',
|
||||
runtimeMode: 'bundled',
|
||||
targets: new Set(['flatpak']),
|
||||
},
|
||||
} as const satisfies Record<RuntimeProfile, RuntimeProfileContract>;
|
||||
|
||||
export const BASE_MANIFEST_FIELDS = [
|
||||
'arch',
|
||||
'artifacts',
|
||||
'generatedAt',
|
||||
'libmpvSoname',
|
||||
'nativeViewFallback',
|
||||
'origin',
|
||||
'packageDependencies',
|
||||
'platform',
|
||||
'processIsolation',
|
||||
'profile',
|
||||
'runtimeFiles',
|
||||
'runtimeMode',
|
||||
'runtimeTotalBytes',
|
||||
'schemaVersion',
|
||||
'targets',
|
||||
] as const;
|
||||
|
||||
export const BUNDLED_MANIFEST_FIELDS = [
|
||||
...BASE_MANIFEST_FIELDS,
|
||||
'externalSystemLibraries',
|
||||
'runtimeDependencyClosure',
|
||||
'sourceArchive',
|
||||
'sourceRuntime',
|
||||
] as const;
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
cloneManifest,
|
||||
createDevelopmentFixture,
|
||||
probeDevelopmentRuntime,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy development manifest', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it.each(['system-dev', 'system-build-inputs', 'bundled-runtime'] as const)(
|
||||
'accepts an exact unpackaged %s build manifest and still runs the helper probe',
|
||||
(buildInputMode) => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
buildInputMode
|
||||
);
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(
|
||||
context.createProbe(),
|
||||
fixture.helperPath
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
runtimeMode:
|
||||
buildInputMode === 'bundled-runtime'
|
||||
? 'bundled'
|
||||
: 'system',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
['--runtime-probe'],
|
||||
expect.objectContaining({
|
||||
env:
|
||||
buildInputMode === 'bundled-runtime'
|
||||
? {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: path.join(
|
||||
fixture.nativeDir,
|
||||
'lib'
|
||||
),
|
||||
}
|
||||
: {
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the packaged manifest contract strict when a development manifest is present', () => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
'bundled-runtime'
|
||||
);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a local bundled runtime before a release source archive is assembled', () => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
'bundled-runtime'
|
||||
);
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest.sourceArchive = null;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'origin',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.origin = 'system-libmpv-frame-copy';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'architecture',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.arch = 'arm64';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'artifact set',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
const artifacts = manifest.artifacts as Record<string, unknown>;
|
||||
artifacts.helper = 'other-helper';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'package availability',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.packageRuntimeAvailability = {
|
||||
system: true,
|
||||
bundled: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'unexpected field',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.manifestContract = 'packaged';
|
||||
},
|
||||
},
|
||||
])(
|
||||
'rejects a development manifest with an invalid $label',
|
||||
({ mutate }) => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
'system-dev'
|
||||
);
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
mutate(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(
|
||||
context.createProbe(),
|
||||
fixture.helperPath
|
||||
)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a system development manifest with a private runtime directory', () => {
|
||||
const fixture = createDevelopmentFixture(context.rootDir, 'system-dev');
|
||||
mkdirSync(path.join(fixture.nativeDir, 'lib'));
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-library-directory-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { accessSync, lstatSync, readFileSync, readdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
|
||||
import { createFixture } from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
const FLATPAK_NATIVE_DIR =
|
||||
'/app/iptvnator/resources/app.asar.unpacked/electron-backend/native';
|
||||
const FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [
|
||||
'/etc/egl/egl_external_platform.d',
|
||||
'/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d',
|
||||
'/usr/share/egl/egl_external_platform.d',
|
||||
].join(':');
|
||||
|
||||
describe('Flatpak embedded MPV frame-copy runtime', () => {
|
||||
it('reconstructs the immutable Freedesktop GL metadata inside the packaged app', () => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/app/bin:/usr/bin',
|
||||
FLATPAK_ID: 'com.fourgray.iptvnator',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
|
||||
'/tmp/hostile-egl-platform.json',
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan',
|
||||
},
|
||||
FLATPAK_NATIVE_DIR,
|
||||
'bundled'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/app/bin:/usr/bin',
|
||||
FLATPAK_ID: 'com.fourgray.iptvnator',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS,
|
||||
LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wrong app id', 'com.example.other', '/app/iptvnator/native'],
|
||||
[
|
||||
'helper outside /app',
|
||||
'com.fourgray.iptvnator',
|
||||
'/opt/iptvnator/native',
|
||||
],
|
||||
])(
|
||||
'does not reconstruct Flatpak GL metadata for %s',
|
||||
(_label, flatpakId, nativeDir) => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
FLATPAK_ID: flatpakId,
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
},
|
||||
nativeDir,
|
||||
'bundled'
|
||||
)
|
||||
).toEqual({
|
||||
FLATPAK_ID: flatpakId,
|
||||
LD_LIBRARY_PATH: path.join(nativeDir, 'lib'),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe('packaged capability probe', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('uses reconstructed Freedesktop GL metadata through the application gate', () => {
|
||||
const fixture = createFixture(context.rootDir, 'flatpak');
|
||||
const virtualHelperPath = path.join(
|
||||
FLATPAK_NATIVE_DIR,
|
||||
'iptvnator_mpv_helper'
|
||||
);
|
||||
const translatePath = (candidatePath: string): string => {
|
||||
if (
|
||||
candidatePath === FLATPAK_NATIVE_DIR ||
|
||||
candidatePath.startsWith(`${FLATPAK_NATIVE_DIR}${path.sep}`)
|
||||
) {
|
||||
return path.join(
|
||||
fixture.nativeDir,
|
||||
path.relative(FLATPAK_NATIVE_DIR, candidatePath)
|
||||
);
|
||||
}
|
||||
return candidatePath;
|
||||
};
|
||||
const probeRuntime = context.createProbe({
|
||||
env: {
|
||||
PATH: '/app/bin:/usr/bin',
|
||||
FLATPAK_ID: 'com.fourgray.iptvnator',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
|
||||
'/tmp/hostile-egl-platform.json',
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
|
||||
},
|
||||
fileSystem: {
|
||||
accessSync: (candidatePath, mode) =>
|
||||
accessSync(translatePath(candidatePath), mode),
|
||||
lstatSync: (candidatePath) =>
|
||||
lstatSync(translatePath(candidatePath)),
|
||||
readFileSync: (candidatePath) =>
|
||||
readFileSync(translatePath(candidatePath)),
|
||||
readdirSync: (candidatePath) =>
|
||||
readdirSync(translatePath(candidatePath)),
|
||||
},
|
||||
});
|
||||
|
||||
expect(probeRuntime(virtualHelperPath)).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
profile: 'flatpak',
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
virtualHelperPath,
|
||||
['--runtime-probe'],
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
PATH: '/app/bin:/usr/bin',
|
||||
FLATPAK_ID: 'com.fourgray.iptvnator',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS,
|
||||
LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
import path from 'path';
|
||||
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
|
||||
|
||||
const HOSTILE_LOADER_ENVIRONMENT = {
|
||||
BASH_ENV: '/tmp/hostile-bash-env',
|
||||
ENV: '/tmp/hostile-shell-env',
|
||||
BASHOPTS: 'extdebug',
|
||||
SHELLOPTS: 'xtrace',
|
||||
PS4: '$(/tmp/hostile-trace-hook)',
|
||||
BASH_XTRACEFD: '9',
|
||||
CDPATH: '/tmp/hostile-cdpath',
|
||||
'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }',
|
||||
LD_AUDIT: '/tmp/audit.so',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
LD_ORIGIN_PATH: '/tmp/hostile-origin',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json',
|
||||
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
|
||||
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
|
||||
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
|
||||
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
|
||||
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
|
||||
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
|
||||
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
|
||||
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
|
||||
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
|
||||
VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
|
||||
} as const;
|
||||
|
||||
const GRAPHICS_SELECTOR_ENVIRONMENT = {
|
||||
LIBGL_ALWAYS_SOFTWARE: '1',
|
||||
EGL_PLATFORM: 'x11',
|
||||
DRI_PRIME: '1',
|
||||
GALLIUM_DRIVER: 'llvmpipe',
|
||||
VDPAU_DRIVER: 'mesa',
|
||||
__GLX_VENDOR_LIBRARY_NAME: 'mesa',
|
||||
__GLX_FORCE_VENDOR_LIBRARY_0: 'mesa',
|
||||
VK_INSTANCE_LAYERS: 'VK_LAYER_MESA_overlay',
|
||||
VK_LOADER_DRIVERS_SELECT: '*mesa*',
|
||||
} as const;
|
||||
|
||||
describe('createLinuxFrameCopyHelperEnvironment', () => {
|
||||
it('removes ambient loader overrides for system packages', () => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
...HOSTILE_LOADER_ENVIRONMENT,
|
||||
},
|
||||
'/opt/iptvnator/native',
|
||||
'system'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves graphics feature and debug selectors', () => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
'/opt/iptvnator/native',
|
||||
'system'
|
||||
)
|
||||
).toEqual(GRAPHICS_SELECTOR_ENVIRONMENT);
|
||||
});
|
||||
|
||||
it('keeps trusted Snap GL and core22 roots ahead of older and generic libraries', () => {
|
||||
const snapRoot = '/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/snap/bin:/usr/bin',
|
||||
SNAP: snapRoot,
|
||||
SNAP_LIBRARY_PATH: [
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/tmp/hostile-gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
'/var/lib/snapd/lib/gl-evil',
|
||||
].join(':'),
|
||||
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
|
||||
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan',
|
||||
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
|
||||
XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs',
|
||||
XDG_DATA_HOME: '/tmp/hostile-xdg-data-home',
|
||||
XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs',
|
||||
...HOSTILE_LOADER_ENVIRONMENT,
|
||||
...GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
},
|
||||
nativeDir,
|
||||
'bundled'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
SNAP: snapRoot,
|
||||
SNAP_LIBRARY_PATH: [
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
].join(':'),
|
||||
SNAP_ARCH: 'amd64',
|
||||
SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu',
|
||||
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
|
||||
...GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
GBM_BACKENDS_PATH: [
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'gbm'
|
||||
),
|
||||
'/var/lib/snapd/lib/gl/gbm',
|
||||
].join(':'),
|
||||
LIBGL_DRIVERS_PATH: path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'dri'
|
||||
),
|
||||
LIBVA_DRIVERS_PATH: path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'dri'
|
||||
),
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'egl',
|
||||
'egl_external_platform.d'
|
||||
),
|
||||
__EGL_VENDOR_LIBRARY_DIRS: [
|
||||
'/var/lib/snapd/lib/glvnd/egl_vendor.d',
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'glvnd',
|
||||
'egl_vendor.d'
|
||||
),
|
||||
].join(':'),
|
||||
VK_LAYER_PATH: [
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'vulkan',
|
||||
'implicit_layer.d'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'vulkan',
|
||||
'explicit_layer.d'
|
||||
),
|
||||
].join(':'),
|
||||
XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'),
|
||||
XDG_CONFIG_DIRS: [
|
||||
path.join(snapRoot, 'etc', 'xdg'),
|
||||
'/etc/xdg',
|
||||
].join(':'),
|
||||
XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'),
|
||||
XDG_DATA_DIRS: [
|
||||
path.join(snapRoot, 'graphics', 'usr', 'share'),
|
||||
path.join(snapRoot, 'gnome-platform', 'usr', 'share'),
|
||||
path.join(snapRoot, 'usr', 'share'),
|
||||
'/usr/share',
|
||||
].join(':'),
|
||||
LD_LIBRARY_PATH: [
|
||||
path.join(nativeDir, 'lib'),
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'vdpau'
|
||||
),
|
||||
'/usr/lib/x86_64-linux-gnu',
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'mesa'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'mesa-egl'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'dri'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'pulseaudio'
|
||||
),
|
||||
path.join(snapRoot, 'lib'),
|
||||
path.join(snapRoot, 'usr', 'lib'),
|
||||
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
|
||||
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
|
||||
].join(':'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/tmp/gnome-platform',
|
||||
'/snap/iptvnator/42/gnome-platform-evil',
|
||||
'gnome-platform',
|
||||
])(
|
||||
'ignores an untrusted Snap desktop runtime declaration: %s',
|
||||
(declaredDesktopRuntime) => {
|
||||
const snapRoot = '/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
|
||||
const helperEnvironment = createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
SNAP: snapRoot,
|
||||
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
|
||||
SNAP_DESKTOP_RUNTIME: declaredDesktopRuntime,
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
},
|
||||
nativeDir,
|
||||
'bundled'
|
||||
);
|
||||
|
||||
expect(helperEnvironment.LD_LIBRARY_PATH?.split(':')).toEqual([
|
||||
path.join(nativeDir, 'lib'),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'vdpau'
|
||||
),
|
||||
'/usr/lib/x86_64-linux-gnu',
|
||||
path.join(snapRoot, 'lib'),
|
||||
path.join(snapRoot, 'usr', 'lib'),
|
||||
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
|
||||
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
|
||||
]);
|
||||
expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain(
|
||||
declaredDesktopRuntime
|
||||
);
|
||||
expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain(
|
||||
'hostile-linux-gnu'
|
||||
);
|
||||
expect(helperEnvironment.SNAP_DESKTOP_RUNTIME).toBeUndefined();
|
||||
expect(helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET).toBe(
|
||||
'x86_64-linux-gnu'
|
||||
);
|
||||
expect(helperEnvironment.SNAP_ARCH).toBe('amd64');
|
||||
}
|
||||
);
|
||||
|
||||
it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/usr/bin',
|
||||
SNAP: '/snap/iptvnator/42',
|
||||
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
|
||||
LD_AUDIT: '/tmp/audit.so',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
},
|
||||
'/opt/iptvnator/native',
|
||||
'bundled'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
SNAP: '/snap/iptvnator/42',
|
||||
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
|
||||
LD_LIBRARY_PATH: '/opt/iptvnator/native/lib',
|
||||
});
|
||||
});
|
||||
});
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import path from 'path';
|
||||
import type { EmbeddedMpvFrameCopyRuntimeMode } from './types';
|
||||
import { resolveTrustedSnapRoot } from './trusted-snap-root';
|
||||
|
||||
const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl';
|
||||
const TRUSTED_SNAP_EGL_VENDOR_ROOT = '/var/lib/snapd/lib/glvnd/egl_vendor.d';
|
||||
const SNAP_DESKTOP_RUNTIME_DIRECTORY = 'gnome-platform';
|
||||
const SNAP_GRAPHICS_RUNTIME_DIRECTORY = 'graphics';
|
||||
const SNAP_X64_LIBRARY_TRIPLET = 'x86_64-linux-gnu';
|
||||
const TRUSTED_SNAP_BASE_LIBRARY_ROOT = '/usr/lib/x86_64-linux-gnu';
|
||||
const TRUSTED_SNAP_HELPER_PATH = '/usr/sbin:/usr/bin:/sbin:/bin';
|
||||
const TRUSTED_FLATPAK_APP_ID = 'com.fourgray.iptvnator';
|
||||
const TRUSTED_FLATPAK_APP_ROOT = '/app';
|
||||
const TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [
|
||||
'/etc/egl/egl_external_platform.d',
|
||||
'/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d',
|
||||
'/usr/share/egl/egl_external_platform.d',
|
||||
].join(':');
|
||||
const UNSAFE_HELPER_ENVIRONMENT_VARIABLES = [
|
||||
'BASH_ENV',
|
||||
'ENV',
|
||||
'BASHOPTS',
|
||||
'SHELLOPTS',
|
||||
'PS4',
|
||||
'BASH_XTRACEFD',
|
||||
'CDPATH',
|
||||
'LD_AUDIT',
|
||||
'LD_LIBRARY_PATH',
|
||||
'LD_ORIGIN_PATH',
|
||||
'LD_PRELOAD',
|
||||
'__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS',
|
||||
'__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES',
|
||||
'__EGL_VENDOR_LIBRARY_DIRS',
|
||||
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
||||
'GBM_BACKEND',
|
||||
'GBM_BACKENDS_PATH',
|
||||
'LIBGL_DRIVERS_PATH',
|
||||
'MESA_LOADER_DRIVER_OVERRIDE',
|
||||
'LIBVA_DRIVER_NAME',
|
||||
'LIBVA_DRIVERS_PATH',
|
||||
'VDPAU_DRIVER_PATH',
|
||||
'VK_DRIVER_FILES',
|
||||
'VK_ICD_FILENAMES',
|
||||
'VK_ADD_DRIVER_FILES',
|
||||
'VK_ADD_LAYER_PATH',
|
||||
'VK_IMPLICIT_LAYER_PATH',
|
||||
'VK_ADD_IMPLICIT_LAYER_PATH',
|
||||
'VK_LAYER_PATH',
|
||||
] as const;
|
||||
|
||||
function isPathInside(
|
||||
parentPath: string,
|
||||
candidatePath: string,
|
||||
allowEqual: boolean
|
||||
): boolean {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
if (relativePath === '') {
|
||||
return allowEqual;
|
||||
}
|
||||
return (
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function getTrustedSnapLibraryPaths(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
snapRoot: string
|
||||
): string[] {
|
||||
const snapLibraryPaths = getTrustedSnapHostGlLibraryPaths(environment);
|
||||
const graphicsLibraryRoot = path.join(
|
||||
snapRoot,
|
||||
SNAP_GRAPHICS_RUNTIME_DIRECTORY,
|
||||
'usr',
|
||||
'lib',
|
||||
SNAP_X64_LIBRARY_TRIPLET
|
||||
);
|
||||
const desktopLibraryPaths = getTrustedSnapDesktopLibraryPaths(
|
||||
environment,
|
||||
snapRoot
|
||||
);
|
||||
|
||||
return [
|
||||
...snapLibraryPaths,
|
||||
graphicsLibraryRoot,
|
||||
path.join(graphicsLibraryRoot, 'vdpau'),
|
||||
// The core22 libedit ABI must win over gnome-3-28's libtinfo5 build.
|
||||
TRUSTED_SNAP_BASE_LIBRARY_ROOT,
|
||||
...desktopLibraryPaths,
|
||||
path.join(snapRoot, 'lib'),
|
||||
path.join(snapRoot, 'usr', 'lib'),
|
||||
path.join(snapRoot, 'lib', SNAP_X64_LIBRARY_TRIPLET),
|
||||
path.join(snapRoot, 'usr', 'lib', SNAP_X64_LIBRARY_TRIPLET),
|
||||
];
|
||||
}
|
||||
|
||||
function getTrustedSnapHostGlLibraryPaths(
|
||||
environment: NodeJS.ProcessEnv
|
||||
): string[] {
|
||||
return (environment.SNAP_LIBRARY_PATH ?? '')
|
||||
.split(':')
|
||||
.filter(Boolean)
|
||||
.filter((libraryPath) => path.isAbsolute(libraryPath))
|
||||
.map((libraryPath) => path.resolve(libraryPath))
|
||||
.filter((libraryPath) =>
|
||||
isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true)
|
||||
);
|
||||
}
|
||||
|
||||
function getTrustedSnapDesktopLibraryPaths(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
snapRoot: string
|
||||
): string[] {
|
||||
const desktopRuntime = resolveTrustedSnapDesktopRuntime(
|
||||
environment,
|
||||
snapRoot
|
||||
);
|
||||
if (!desktopRuntime) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const desktopLibraryRoot = path.join(
|
||||
desktopRuntime,
|
||||
'usr',
|
||||
'lib',
|
||||
SNAP_X64_LIBRARY_TRIPLET
|
||||
);
|
||||
return [
|
||||
path.join(desktopRuntime, 'lib', SNAP_X64_LIBRARY_TRIPLET),
|
||||
desktopLibraryRoot,
|
||||
path.join(desktopLibraryRoot, 'mesa'),
|
||||
path.join(desktopLibraryRoot, 'mesa-egl'),
|
||||
path.join(desktopLibraryRoot, 'dri'),
|
||||
path.join(desktopLibraryRoot, 'pulseaudio'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveTrustedSnapDesktopRuntime(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
snapRoot: string
|
||||
): string | null {
|
||||
const expectedDesktopRuntime = path.join(
|
||||
snapRoot,
|
||||
SNAP_DESKTOP_RUNTIME_DIRECTORY
|
||||
);
|
||||
const declaredDesktopRuntime = environment.SNAP_DESKTOP_RUNTIME;
|
||||
if (
|
||||
!declaredDesktopRuntime ||
|
||||
!path.isAbsolute(declaredDesktopRuntime) ||
|
||||
path.resolve(declaredDesktopRuntime) !== expectedDesktopRuntime
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return expectedDesktopRuntime;
|
||||
}
|
||||
|
||||
function isTrustedFlatpakRuntime(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
nativeDir: string
|
||||
): boolean {
|
||||
return (
|
||||
environment.FLATPAK_ID === TRUSTED_FLATPAK_APP_ID &&
|
||||
path.isAbsolute(nativeDir) &&
|
||||
isPathInside(TRUSTED_FLATPAK_APP_ROOT, path.resolve(nativeDir), false)
|
||||
);
|
||||
}
|
||||
|
||||
function applyTrustedSnapGraphicsEnvironment(
|
||||
helperEnvironment: NodeJS.ProcessEnv,
|
||||
sourceEnvironment: NodeJS.ProcessEnv,
|
||||
snapRoot: string
|
||||
): void {
|
||||
const graphicsRuntime = path.join(
|
||||
snapRoot,
|
||||
SNAP_GRAPHICS_RUNTIME_DIRECTORY,
|
||||
'usr'
|
||||
);
|
||||
const graphicsLibraryRoot = path.join(
|
||||
graphicsRuntime,
|
||||
'lib',
|
||||
SNAP_X64_LIBRARY_TRIPLET
|
||||
);
|
||||
const graphicsDriRoot = path.join(graphicsLibraryRoot, 'dri');
|
||||
|
||||
helperEnvironment.GBM_BACKENDS_PATH = [
|
||||
path.join(graphicsLibraryRoot, 'gbm'),
|
||||
path.join(TRUSTED_SNAP_GL_ROOT, 'gbm'),
|
||||
].join(':');
|
||||
helperEnvironment.LIBGL_DRIVERS_PATH = graphicsDriRoot;
|
||||
helperEnvironment.LIBVA_DRIVERS_PATH = graphicsDriRoot;
|
||||
helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = path.join(
|
||||
graphicsRuntime,
|
||||
'share',
|
||||
'egl',
|
||||
'egl_external_platform.d'
|
||||
);
|
||||
helperEnvironment.__EGL_VENDOR_LIBRARY_DIRS = [
|
||||
TRUSTED_SNAP_EGL_VENDOR_ROOT,
|
||||
path.join(graphicsRuntime, 'share', 'glvnd', 'egl_vendor.d'),
|
||||
].join(':');
|
||||
helperEnvironment.VK_LAYER_PATH = [
|
||||
path.join(graphicsRuntime, 'share', 'vulkan', 'implicit_layer.d'),
|
||||
path.join(graphicsRuntime, 'share', 'vulkan', 'explicit_layer.d'),
|
||||
].join(':');
|
||||
|
||||
const snapConfigRoot = path.join(snapRoot, 'etc', 'xdg');
|
||||
const snapDataRoot = path.join(snapRoot, 'usr', 'share');
|
||||
const desktopRuntime = resolveTrustedSnapDesktopRuntime(
|
||||
sourceEnvironment,
|
||||
snapRoot
|
||||
);
|
||||
const snapLibraryPaths =
|
||||
getTrustedSnapHostGlLibraryPaths(sourceEnvironment);
|
||||
if (snapLibraryPaths.length > 0) {
|
||||
helperEnvironment.SNAP_LIBRARY_PATH = snapLibraryPaths.join(':');
|
||||
} else {
|
||||
delete helperEnvironment.SNAP_LIBRARY_PATH;
|
||||
}
|
||||
helperEnvironment.SNAP_ARCH = 'amd64';
|
||||
helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET = SNAP_X64_LIBRARY_TRIPLET;
|
||||
if (desktopRuntime) {
|
||||
helperEnvironment.SNAP_DESKTOP_RUNTIME = desktopRuntime;
|
||||
} else {
|
||||
delete helperEnvironment.SNAP_DESKTOP_RUNTIME;
|
||||
}
|
||||
helperEnvironment.XDG_CONFIG_HOME = snapConfigRoot;
|
||||
helperEnvironment.XDG_CONFIG_DIRS = [snapConfigRoot, '/etc/xdg'].join(':');
|
||||
helperEnvironment.XDG_DATA_HOME = snapDataRoot;
|
||||
helperEnvironment.XDG_DATA_DIRS = [
|
||||
path.join(graphicsRuntime, 'share'),
|
||||
...(desktopRuntime ? [path.join(desktopRuntime, 'usr', 'share')] : []),
|
||||
snapDataRoot,
|
||||
'/usr/share',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the loader environment shared by the bounded startup probe and each
|
||||
* real Linux helper session. The validated package profile is authoritative:
|
||||
* system packages use the system loader, while bundled packages start at
|
||||
* native/lib and add only fixed trusted Snap or Flatpak graphics roots.
|
||||
*/
|
||||
export function createLinuxFrameCopyHelperEnvironment(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
nativeDir: string,
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode
|
||||
): NodeJS.ProcessEnv {
|
||||
const helperEnvironment = { ...environment };
|
||||
for (const variableName of UNSAFE_HELPER_ENVIRONMENT_VARIABLES) {
|
||||
delete helperEnvironment[variableName];
|
||||
}
|
||||
for (const variableName of Object.keys(helperEnvironment)) {
|
||||
if (variableName.startsWith('BASH_FUNC_')) {
|
||||
delete helperEnvironment[variableName];
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeMode === 'system') {
|
||||
return helperEnvironment;
|
||||
}
|
||||
|
||||
const libraryPaths = [path.join(nativeDir, 'lib')];
|
||||
const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir);
|
||||
if (trustedSnapRoot) {
|
||||
helperEnvironment.PATH = TRUSTED_SNAP_HELPER_PATH;
|
||||
libraryPaths.push(
|
||||
...getTrustedSnapLibraryPaths(environment, trustedSnapRoot)
|
||||
);
|
||||
applyTrustedSnapGraphicsEnvironment(
|
||||
helperEnvironment,
|
||||
environment,
|
||||
trustedSnapRoot
|
||||
);
|
||||
} else if (isTrustedFlatpakRuntime(environment, nativeDir)) {
|
||||
helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS =
|
||||
TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS;
|
||||
}
|
||||
helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':');
|
||||
return helperEnvironment;
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import { SUCCESS_OUTPUT } from './runtime.spec-data';
|
||||
import { createFixture } from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy helper failures', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('converts a thrown spawn failure into a stable result', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockImplementation(() => {
|
||||
throw new Error('spawn exploded');
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-spawn-error',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'timeout',
|
||||
spawnResult: {
|
||||
status: null,
|
||||
signal: 'SIGTERM',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: Object.assign(new Error('timed out'), {
|
||||
code: 'ETIMEDOUT',
|
||||
}),
|
||||
},
|
||||
reason: 'helper-probe-timeout',
|
||||
},
|
||||
{
|
||||
label: 'spawn error',
|
||||
spawnResult: {
|
||||
status: null,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: Object.assign(new Error('spawn failed'), {
|
||||
code: 'EACCES',
|
||||
}),
|
||||
},
|
||||
reason: 'helper-probe-spawn-error',
|
||||
},
|
||||
{
|
||||
label: 'nonzero exit',
|
||||
spawnResult: {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-failed',
|
||||
},
|
||||
{
|
||||
label: 'signal',
|
||||
spawnResult: {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-signaled',
|
||||
},
|
||||
{
|
||||
label: 'invalid JSON',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: 'not-json\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-invalid-output',
|
||||
},
|
||||
{
|
||||
label: 'multiple lines',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`,
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-invalid-output',
|
||||
},
|
||||
{
|
||||
label: 'wrong protocol',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-protocol-mismatch',
|
||||
},
|
||||
{
|
||||
label: 'unusable success',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-unusable',
|
||||
},
|
||||
])('fails closed on helper $label', ({ spawnResult, reason }) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockReturnValue(spawnResult);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual(
|
||||
expect.objectContaining({ usable: false, reason })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'mpv-create-failed',
|
||||
'mpv-initialize-failed',
|
||||
'gl-context-create-failed',
|
||||
'gl-context-bind-failed',
|
||||
'mpv-render-context-failed',
|
||||
'shared-memory-create-failed',
|
||||
'shared-memory-initialize-failed',
|
||||
])('preserves the allowlisted helper reason %s', (helperReason) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const helperDetail =
|
||||
helperReason === 'mpv-create-failed'
|
||||
? undefined
|
||||
: 'EGL initialization failed: display unavailable';
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: `${JSON.stringify({
|
||||
protocol: 1,
|
||||
usable: false,
|
||||
reason: helperReason,
|
||||
...(helperDetail ? { detail: helperDetail } : {}),
|
||||
})}\n`,
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
helperReason,
|
||||
...(helperDetail ? { helperDetail } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['one printable ASCII character', 'x'],
|
||||
['1024 printable ASCII characters', 'x'.repeat(1024)],
|
||||
])('preserves %s as helper detail', (_label, helperDetail) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: `${JSON.stringify({
|
||||
protocol: 1,
|
||||
usable: false,
|
||||
reason: 'gl-context-create-failed',
|
||||
detail: helperDetail,
|
||||
})}\n`,
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
helperReason: 'gl-context-create-failed',
|
||||
helperDetail,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', 'not-json\n'],
|
||||
[
|
||||
'multiple lines',
|
||||
'{"protocol":1,"usable":false,"reason":"mpv-create-failed"}\nignored\n',
|
||||
],
|
||||
[
|
||||
'wrong protocol',
|
||||
'{"protocol":2,"usable":false,"reason":"mpv-create-failed"}\n',
|
||||
],
|
||||
[
|
||||
'wrong usable value',
|
||||
'{"protocol":1,"usable":true,"reason":"mpv-create-failed"}\n',
|
||||
],
|
||||
[
|
||||
'non-allowlisted reason',
|
||||
'{"protocol":1,"usable":false,"reason":"loader-injected"}\n',
|
||||
],
|
||||
[
|
||||
'unexpected field',
|
||||
'{"protocol":1,"usable":false,"reason":"mpv-create-failed","extra":true}\n',
|
||||
],
|
||||
[
|
||||
'invalid detail',
|
||||
'{"protocol":1,"usable":false,"reason":"mpv-create-failed","detail":1}\n',
|
||||
],
|
||||
])('does not propagate a helper reason from %s', (_label, stdout) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout,
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty', ''],
|
||||
['control character', 'EGL\tfailure'],
|
||||
['trailing line feed', 'EGL failure\n'],
|
||||
['DEL character', `EGL${String.fromCharCode(0x7f)}failure`],
|
||||
['non-ASCII character', 'EGL échoué'],
|
||||
['1025 characters', 'x'.repeat(1025)],
|
||||
])(
|
||||
'does not propagate any helper fields from %s detail',
|
||||
(_label, detail) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: `${JSON.stringify({
|
||||
protocol: 1,
|
||||
usable: false,
|
||||
reason: 'gl-context-create-failed',
|
||||
detail,
|
||||
})}\n`,
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('does not trace helper stderr without the exact player trace flag', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
|
||||
stderr: 'libEGL debug output\n',
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
helperReason: 'gl-context-create-failed',
|
||||
});
|
||||
expect(context.writeRuntimeProbeStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('traces helper stderr as one JSON-escaped line when player tracing is enabled', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const helperStderr =
|
||||
'libEGL warning: vendor "mesa"\nfailed path: C:\\driver';
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
|
||||
stderr: helperStderr,
|
||||
});
|
||||
|
||||
expect(
|
||||
context.createProbe({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
IPTVNATOR_TRACE_PLAYER: '1',
|
||||
},
|
||||
})(fixture.helperPath)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
helperReason: 'gl-context-create-failed',
|
||||
});
|
||||
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledWith(
|
||||
`${JSON.stringify({
|
||||
event: 'embedded-mpv-helper-runtime-probe-stderr',
|
||||
stderr: helperStderr,
|
||||
truncated: false,
|
||||
})}\n`
|
||||
);
|
||||
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
context.writeRuntimeProbeStderr.mock.calls[0][0].split('\n')
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('bounds traced helper stderr to 16384 characters and reports truncation', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const helperStderr = `${'x'.repeat(16_384)}discarded`;
|
||||
context.spawnRuntimeProbe.mockReturnValue({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
|
||||
stderr: helperStderr,
|
||||
});
|
||||
|
||||
context.createProbe({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
IPTVNATOR_TRACE_PLAYER: '1',
|
||||
},
|
||||
})(fixture.helperPath);
|
||||
|
||||
const trace = JSON.parse(
|
||||
context.writeRuntimeProbeStderr.mock.calls[0][0]
|
||||
);
|
||||
expect(trace).toEqual({
|
||||
event: 'embedded-mpv-helper-runtime-probe-stderr',
|
||||
stderr: 'x'.repeat(16_384),
|
||||
truncated: true,
|
||||
});
|
||||
expect(trace.stderr).toHaveLength(16_384);
|
||||
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import type { Stats } from 'fs';
|
||||
import path from 'path';
|
||||
import { createLinuxFrameCopyHelperLaunch } from '../embedded-mpv-frame-copy-runtime';
|
||||
|
||||
function fakeStat(
|
||||
kind: 'directory' | 'file' | 'symlink'
|
||||
): Pick<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
|
||||
return {
|
||||
isDirectory: () => kind === 'directory',
|
||||
isFile: () => kind === 'file',
|
||||
isSymbolicLink: () => kind === 'symlink',
|
||||
};
|
||||
}
|
||||
|
||||
describe('createLinuxFrameCopyHelperLaunch', () => {
|
||||
const helperArgs = ['--runtime-probe'];
|
||||
|
||||
it.each([
|
||||
['system', '/opt/iptvnator/native/iptvnator_mpv_helper'],
|
||||
[
|
||||
'bundled',
|
||||
'/tmp/.mount-IPTVnator/resources/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper',
|
||||
],
|
||||
] as const)(
|
||||
'launches a non-Snap %s helper directly',
|
||||
(runtimeMode, helperPath) => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperLaunch({
|
||||
environment: {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile',
|
||||
},
|
||||
helperPath,
|
||||
helperArgs,
|
||||
runtimeMode,
|
||||
})
|
||||
).toEqual({
|
||||
usable: true,
|
||||
command: helperPath,
|
||||
args: helperArgs,
|
||||
env:
|
||||
runtimeMode === 'system'
|
||||
? { PATH: '/usr/bin' }
|
||||
: {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: path.join(
|
||||
path.dirname(helperPath),
|
||||
'lib'
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('launches a trusted Snap helper through the connected provider wrapper', () => {
|
||||
const snapRoot = '/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
|
||||
const graphicsRoot = path.join(snapRoot, 'graphics');
|
||||
const wrapperPath = path.join(
|
||||
graphicsRoot,
|
||||
'bin',
|
||||
'graphics-core22-provider-wrapper'
|
||||
);
|
||||
const lstatSync = jest.fn((candidatePath: string) => {
|
||||
if (candidatePath === graphicsRoot) {
|
||||
return fakeStat('directory') as Stats;
|
||||
}
|
||||
if (candidatePath === wrapperPath) {
|
||||
return fakeStat('file') as Stats;
|
||||
}
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
});
|
||||
const accessSync = jest.fn();
|
||||
|
||||
expect(
|
||||
createLinuxFrameCopyHelperLaunch({
|
||||
environment: {
|
||||
PATH: '/snap/bin:/usr/bin',
|
||||
SNAP: snapRoot,
|
||||
},
|
||||
helperPath,
|
||||
helperArgs,
|
||||
runtimeMode: 'bundled',
|
||||
fileSystem: { lstatSync, accessSync },
|
||||
})
|
||||
).toEqual({
|
||||
usable: true,
|
||||
command: wrapperPath,
|
||||
args: [helperPath, ...helperArgs],
|
||||
env: expect.objectContaining({
|
||||
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
SNAP: snapRoot,
|
||||
LD_LIBRARY_PATH: expect.stringContaining(
|
||||
path.join(nativeDir, 'lib')
|
||||
),
|
||||
}),
|
||||
});
|
||||
expect(lstatSync).toHaveBeenCalledWith(graphicsRoot);
|
||||
expect(lstatSync).toHaveBeenCalledWith(wrapperPath);
|
||||
expect(accessSync).toHaveBeenCalledWith(
|
||||
wrapperPath,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing mount', 'missing', 'file'],
|
||||
['symlink mount', 'symlink', 'file'],
|
||||
['missing wrapper', 'directory', 'missing'],
|
||||
['symlink wrapper', 'directory', 'symlink'],
|
||||
] as const)(
|
||||
'fails closed for a trusted Snap with a %s',
|
||||
(_label, mountKind, wrapperKind) => {
|
||||
const snapRoot = '/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
|
||||
const graphicsRoot = path.join(snapRoot, 'graphics');
|
||||
const wrapperPath = path.join(
|
||||
graphicsRoot,
|
||||
'bin',
|
||||
'graphics-core22-provider-wrapper'
|
||||
);
|
||||
|
||||
expect(
|
||||
createLinuxFrameCopyHelperLaunch({
|
||||
environment: { SNAP: snapRoot },
|
||||
helperPath,
|
||||
helperArgs,
|
||||
runtimeMode: 'bundled',
|
||||
fileSystem: {
|
||||
lstatSync: (candidatePath) => {
|
||||
const kind =
|
||||
candidatePath === graphicsRoot
|
||||
? mountKind
|
||||
: wrapperKind;
|
||||
if (kind === 'missing') {
|
||||
throw Object.assign(new Error('missing'), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}
|
||||
return fakeStat(kind) as Stats;
|
||||
},
|
||||
accessSync: () => undefined,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'snap-graphics-provider-unavailable',
|
||||
});
|
||||
|
||||
expect(wrapperPath).toContain('/graphics/bin/');
|
||||
}
|
||||
);
|
||||
|
||||
it('fails closed when the provider wrapper is not executable', () => {
|
||||
const snapRoot = '/var/lib/snapd/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
|
||||
expect(
|
||||
createLinuxFrameCopyHelperLaunch({
|
||||
environment: { SNAP: snapRoot },
|
||||
helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'),
|
||||
helperArgs,
|
||||
runtimeMode: 'bundled',
|
||||
fileSystem: {
|
||||
lstatSync: (candidatePath) =>
|
||||
fakeStat(
|
||||
candidatePath.endsWith('/graphics')
|
||||
? 'directory'
|
||||
: 'file'
|
||||
) as Stats,
|
||||
accessSync: () => {
|
||||
throw Object.assign(new Error('denied'), {
|
||||
code: 'EACCES',
|
||||
});
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'snap-graphics-provider-unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
accessSync as nodeAccessSync,
|
||||
constants as fileSystemConstants,
|
||||
lstatSync as nodeLstatSync,
|
||||
} from 'fs';
|
||||
import type * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import { createLinuxFrameCopyHelperEnvironment } from './helper-environment';
|
||||
import { resolveTrustedSnapRoot } from './trusted-snap-root';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyRuntimeFailureReason,
|
||||
EmbeddedMpvFrameCopyRuntimeMode,
|
||||
} from './types';
|
||||
|
||||
export interface LinuxFrameCopyHelperLaunchFileSystem {
|
||||
lstatSync(filePath: string): nodeFileSystem.Stats;
|
||||
accessSync(filePath: string, mode: number): void;
|
||||
}
|
||||
|
||||
interface CreateLinuxFrameCopyHelperLaunchOptions {
|
||||
environment: NodeJS.ProcessEnv;
|
||||
helperPath: string;
|
||||
helperArgs: string[];
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
fileSystem?: LinuxFrameCopyHelperLaunchFileSystem;
|
||||
}
|
||||
|
||||
export type LinuxFrameCopyHelperLaunch =
|
||||
| {
|
||||
usable: true;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
| {
|
||||
usable: false;
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
|
||||
};
|
||||
|
||||
export function createLinuxFrameCopyHelperLaunch(
|
||||
options: CreateLinuxFrameCopyHelperLaunchOptions
|
||||
): LinuxFrameCopyHelperLaunch {
|
||||
const nativeDir = path.dirname(options.helperPath);
|
||||
const env = createLinuxFrameCopyHelperEnvironment(
|
||||
options.environment,
|
||||
nativeDir,
|
||||
options.runtimeMode
|
||||
);
|
||||
const trustedSnapRoot =
|
||||
options.runtimeMode === 'bundled'
|
||||
? resolveTrustedSnapRoot(options.environment, nativeDir)
|
||||
: null;
|
||||
if (!trustedSnapRoot) {
|
||||
return {
|
||||
usable: true,
|
||||
command: options.helperPath,
|
||||
args: options.helperArgs,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
const graphicsRoot = path.join(trustedSnapRoot, 'graphics');
|
||||
const providerWrapperPath = path.join(
|
||||
graphicsRoot,
|
||||
'bin',
|
||||
'graphics-core22-provider-wrapper'
|
||||
);
|
||||
const fileSystem = options.fileSystem ?? {
|
||||
lstatSync: nodeLstatSync,
|
||||
accessSync: nodeAccessSync,
|
||||
};
|
||||
try {
|
||||
const graphicsRootStat = fileSystem.lstatSync(graphicsRoot);
|
||||
const providerWrapperStat = fileSystem.lstatSync(providerWrapperPath);
|
||||
if (
|
||||
!graphicsRootStat.isDirectory() ||
|
||||
graphicsRootStat.isSymbolicLink() ||
|
||||
!providerWrapperStat.isFile() ||
|
||||
providerWrapperStat.isSymbolicLink()
|
||||
) {
|
||||
return {
|
||||
usable: false,
|
||||
reason: 'snap-graphics-provider-unavailable',
|
||||
};
|
||||
}
|
||||
fileSystem.accessSync(
|
||||
providerWrapperPath,
|
||||
fileSystemConstants.R_OK | fileSystemConstants.X_OK
|
||||
);
|
||||
} catch {
|
||||
return {
|
||||
usable: false,
|
||||
reason: 'snap-graphics-provider-unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
usable: true,
|
||||
command: providerWrapperPath,
|
||||
args: [options.helperPath, ...options.helperArgs],
|
||||
env,
|
||||
};
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { unlinkSync, writeFileSync } from 'fs';
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
mirrorBundledManifestFields,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy packaged manifest policy', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('rejects a missing or malformed manifest without throwing', () => {
|
||||
const missing = createFixture(context.rootDir);
|
||||
unlinkSync(missing.manifestPath);
|
||||
expect(context.createProbe()(missing.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-missing',
|
||||
});
|
||||
|
||||
const malformed = createFixture(context.rootDir, 'portable');
|
||||
writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 });
|
||||
expect(context.createProbe()(malformed.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['origin', 'system-libmpv-frame-copy'],
|
||||
['arch', 'arm64'],
|
||||
['runtimeMode', 'system'],
|
||||
['targets', ['deb']],
|
||||
['sourceArchive', null],
|
||||
['unexpectedField', true],
|
||||
])('rejects a bundled profile mismatch in %s', (field, value) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest[field] = value;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['system', ['deb', 'pacman']],
|
||||
['portable', ['appimage']],
|
||||
] as const)(
|
||||
'rejects an allowed subset of the exact %s profile targets',
|
||||
(profile, targets) => {
|
||||
const fixture = createFixture(context.rootDir, profile);
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest.targets = targets;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a bundled closure dependency outside the deterministic system allowlist', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const closure = manifest.runtimeDependencyClosure as {
|
||||
entries: Array<{ needed: string[] }>;
|
||||
externalDependencies: string[];
|
||||
};
|
||||
closure.entries[0].needed = ['libambient-only.so.1'];
|
||||
closure.externalDependencies = ['libambient-only.so.1'];
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires externalDependencies to exactly equal the sorted external closure', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const closure = manifest.runtimeDependencyClosure as {
|
||||
entries: Array<{ needed: string[] }>;
|
||||
externalDependencies: string[];
|
||||
};
|
||||
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
|
||||
closure.externalDependencies = [];
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts only the exact deterministic external-system library declaration', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
(manifest.externalSystemLibraries as unknown[]).pop();
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const closure = manifest.runtimeDependencyClosure as {
|
||||
entries: Array<{ needed: string[] }>;
|
||||
externalDependencies: string[];
|
||||
};
|
||||
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
|
||||
closure.externalDependencies = ['libEGL.so.1', 'libc.so.6'];
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual(
|
||||
expect.objectContaining({ usable: true, runtimeMode: 'bundled' })
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
BASE_MANIFEST_FIELDS,
|
||||
BUNDLED_MANIFEST_FIELDS,
|
||||
DEVELOPMENT_MANIFEST_FIELDS,
|
||||
EXPECTED_ARTIFACTS,
|
||||
EXPECTED_DEVELOPMENT_ARTIFACTS,
|
||||
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION,
|
||||
EXPECTED_PROCESS_ISOLATION,
|
||||
PROFILE_CONTRACTS,
|
||||
validateLinuxSourceArchiveBinding,
|
||||
SYSTEM_PACKAGE_DEPENDENCIES,
|
||||
VERSIONED_LIBMPV_PATTERN,
|
||||
} from './contracts';
|
||||
import { validateRuntimeClosure } from './runtime-closure-validator';
|
||||
import { validateSourceRuntimePolicy } from './source-runtime-validator';
|
||||
import type { RuntimeProfile, ValidManifest, ValidationResult } from './types';
|
||||
import {
|
||||
hasExactFields,
|
||||
isObject,
|
||||
validateRuntimeFiles,
|
||||
validateTargets,
|
||||
validationFailure,
|
||||
} from './validation-primitives';
|
||||
|
||||
export function validatePackagedManifest(
|
||||
manifest: Record<string, unknown>
|
||||
): ValidationResult<ValidManifest> {
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.platform !== 'linux' ||
|
||||
manifest.arch !== 'x64' ||
|
||||
typeof manifest.profile !== 'string' ||
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
PROFILE_CONTRACTS,
|
||||
manifest.profile
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
const profile = manifest.profile as RuntimeProfile;
|
||||
const contract = PROFILE_CONTRACTS[profile];
|
||||
if (
|
||||
manifest.origin !== contract.origin ||
|
||||
manifest.runtimeMode !== contract.runtimeMode ||
|
||||
!hasExactFields(
|
||||
manifest,
|
||||
contract.runtimeMode === 'bundled'
|
||||
? BUNDLED_MANIFEST_FIELDS
|
||||
: BASE_MANIFEST_FIELDS
|
||||
) ||
|
||||
typeof manifest.generatedAt !== 'string' ||
|
||||
manifest.generatedAt.trim() === '' ||
|
||||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
|
||||
!validateTargets(manifest.targets, contract.targets) ||
|
||||
!isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) ||
|
||||
!isDeepStrictEqual(
|
||||
manifest.processIsolation,
|
||||
EXPECTED_PROCESS_ISOLATION
|
||||
) ||
|
||||
manifest.nativeViewFallback !== 'process-isolated mpv --wid' ||
|
||||
typeof manifest.libmpvSoname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
if (contract.runtimeMode === 'system') {
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
manifest.packageDependencies,
|
||||
SYSTEM_PACKAGE_DEPENDENCIES
|
||||
) ||
|
||||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
|
||||
manifest.runtimeTotalBytes !== 0
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
profile,
|
||||
runtimeMode: 'system',
|
||||
runtimeFiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
|
||||
if (
|
||||
!runtimeFiles ||
|
||||
!isDeepStrictEqual(manifest.packageDependencies, {}) ||
|
||||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
|
||||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
|
||||
manifest.runtimeTotalBytes !==
|
||||
runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
) ||
|
||||
!validateRuntimeClosure(
|
||||
manifest.runtimeDependencyClosure,
|
||||
runtimeFiles,
|
||||
manifest.libmpvSoname,
|
||||
manifest.externalSystemLibraries
|
||||
) ||
|
||||
validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !==
|
||||
0 ||
|
||||
!validateSourceRuntimePolicy(
|
||||
manifest.sourceRuntime,
|
||||
runtimeFiles,
|
||||
manifest.runtimeDependencyClosure,
|
||||
manifest.externalSystemLibraries
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
profile,
|
||||
runtimeMode: 'bundled',
|
||||
runtimeFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSystemDevelopmentSource(
|
||||
value: unknown,
|
||||
buildInputMode: 'system-dev' | 'system-build-inputs'
|
||||
): boolean {
|
||||
if (buildInputMode === 'system-dev') {
|
||||
return isDeepStrictEqual(value, {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
warning: 'Development-only unmanaged system libmpv toolchain.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!hasExactFields(value, [
|
||||
'buildInputs',
|
||||
'linuxBackend',
|
||||
'sourceDistribution',
|
||||
]) ||
|
||||
value.linuxBackend !== 'process-isolated mpv --wid' ||
|
||||
typeof value.sourceDistribution !== 'string' ||
|
||||
value.sourceDistribution.trim() === '' ||
|
||||
!isObject(value.buildInputs) ||
|
||||
!hasExactFields(value.buildInputs, ['libmpvDevPackage', 'mpvPackage'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return ['libmpvDevPackage', 'mpvPackage'].every((packageField) => {
|
||||
const packageName = value.buildInputs[packageField];
|
||||
return typeof packageName === 'string' && packageName.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDevelopmentManifest(
|
||||
manifest: Record<string, unknown>
|
||||
): ValidationResult<ValidManifest> {
|
||||
const buildInputMode = manifest.buildInputMode;
|
||||
if (
|
||||
!hasExactFields(manifest, DEVELOPMENT_MANIFEST_FIELDS) ||
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.origin !== 'linux-frame-copy-build' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
manifest.arch !== 'x64' ||
|
||||
typeof manifest.generatedAt !== 'string' ||
|
||||
manifest.generatedAt.trim() === '' ||
|
||||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
|
||||
!['system-dev', 'system-build-inputs', 'bundled-runtime'].includes(
|
||||
String(buildInputMode)
|
||||
) ||
|
||||
!isDeepStrictEqual(manifest.allowedPackageRuntimeModes, [
|
||||
'system',
|
||||
'bundled',
|
||||
]) ||
|
||||
!isDeepStrictEqual(
|
||||
manifest.artifacts,
|
||||
EXPECTED_DEVELOPMENT_ARTIFACTS
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
manifest.processIsolation,
|
||||
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION
|
||||
) ||
|
||||
manifest.nativeViewFallback !== 'process-isolated mpv --wid'
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
if (
|
||||
buildInputMode === 'system-dev' ||
|
||||
buildInputMode === 'system-build-inputs'
|
||||
) {
|
||||
if (
|
||||
manifest.sourceRuntimeValidated !== false ||
|
||||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
|
||||
system: false,
|
||||
bundled: false,
|
||||
}) ||
|
||||
manifest.libmpvSoname !== null ||
|
||||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
|
||||
manifest.runtimeTotalBytes !== 0 ||
|
||||
manifest.sourceArchive !== null ||
|
||||
!validateSystemDevelopmentSource(
|
||||
manifest.sourceRuntime,
|
||||
buildInputMode
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
profile: 'development',
|
||||
runtimeMode: 'system',
|
||||
runtimeFiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sourceRuntime = manifest.sourceRuntime;
|
||||
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
|
||||
if (
|
||||
buildInputMode !== 'bundled-runtime' ||
|
||||
manifest.sourceRuntimeValidated !== true ||
|
||||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
|
||||
system: true,
|
||||
bundled: true,
|
||||
}) ||
|
||||
typeof manifest.libmpvSoname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) ||
|
||||
!runtimeFiles ||
|
||||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
|
||||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
|
||||
manifest.runtimeTotalBytes !==
|
||||
runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
) ||
|
||||
!isObject(sourceRuntime) ||
|
||||
(manifest.sourceArchive !== null &&
|
||||
validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !==
|
||||
0) ||
|
||||
!validateRuntimeClosure(
|
||||
sourceRuntime.runtimeDependencyClosure,
|
||||
runtimeFiles,
|
||||
manifest.libmpvSoname,
|
||||
sourceRuntime.externalSystemLibraries
|
||||
) ||
|
||||
!validateSourceRuntimePolicy(
|
||||
sourceRuntime,
|
||||
runtimeFiles,
|
||||
sourceRuntime.runtimeDependencyClosure,
|
||||
sourceRuntime.externalSystemLibraries
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
profile: 'development',
|
||||
runtimeMode: 'bundled',
|
||||
runtimeFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
chmodSync,
|
||||
constants as fsConstants,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import path from 'path';
|
||||
import type { RuntimeFile, RuntimeFixture } from './runtime.spec-data';
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy package integrity', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('rejects system manifests with a private library directory', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
mkdirSync(path.join(fixture.nativeDir, 'lib'));
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-library-directory-invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'missing addon',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node'));
|
||||
},
|
||||
reason: 'runtime-artifact-missing',
|
||||
},
|
||||
{
|
||||
label: 'non-executable helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(fixture.helperPath, 0o644);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'setuid helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(fixture.helperPath, 0o4755);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'setgid helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(fixture.helperPath, 0o2755);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'sticky helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(fixture.helperPath, 0o1755);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'wrong reader mode',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(
|
||||
path.join(
|
||||
fixture.nativeDir,
|
||||
'embedded_mpv_frame_reader.node'
|
||||
),
|
||||
0o600
|
||||
);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'symlinked helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const target = `${fixture.helperPath}.real`;
|
||||
writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 });
|
||||
unlinkSync(fixture.helperPath);
|
||||
symlinkSync(target, fixture.helperPath);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'reader directory',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const readerPath = path.join(
|
||||
fixture.nativeDir,
|
||||
'embedded_mpv_frame_reader.node'
|
||||
);
|
||||
unlinkSync(readerPath);
|
||||
mkdirSync(readerPath);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
])('rejects a $label', ({ mutate, reason }) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
mutate(fixture);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason,
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'missing declared library',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'));
|
||||
},
|
||||
reason: 'runtime-library-missing',
|
||||
},
|
||||
{
|
||||
label: 'undeclared library',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
writeFileSync(
|
||||
path.join(fixture.nativeDir, 'lib', 'libextra.so'),
|
||||
'extra'
|
||||
);
|
||||
},
|
||||
reason: 'runtime-library-undeclared',
|
||||
},
|
||||
{
|
||||
label: 'library size mismatch',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
writeFileSync(
|
||||
path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'),
|
||||
'different-length'
|
||||
);
|
||||
},
|
||||
reason: 'runtime-library-size-mismatch',
|
||||
},
|
||||
{
|
||||
label: 'library hash mismatch',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const runtimePath = path.join(
|
||||
fixture.nativeDir,
|
||||
'lib',
|
||||
'libmpv.so.2'
|
||||
);
|
||||
const original = readFileSync(runtimePath);
|
||||
writeFileSync(
|
||||
runtimePath,
|
||||
Buffer.from(original.map((value) => value ^ 0xff))
|
||||
);
|
||||
},
|
||||
reason: 'runtime-library-hash-mismatch',
|
||||
},
|
||||
{
|
||||
label: 'symlinked library',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const runtimePath = path.join(
|
||||
fixture.nativeDir,
|
||||
'lib',
|
||||
'libmpv.so.2'
|
||||
);
|
||||
const targetPath = path.join(
|
||||
fixture.nativeDir,
|
||||
'libmpv-real.so.2'
|
||||
);
|
||||
writeFileSync(
|
||||
targetPath,
|
||||
fixture.runtimeContents['libmpv.so.2']
|
||||
);
|
||||
unlinkSync(runtimePath);
|
||||
symlinkSync(targetPath, runtimePath);
|
||||
},
|
||||
reason: 'runtime-library-invalid',
|
||||
},
|
||||
{
|
||||
label: 'library directory',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const runtimePath = path.join(
|
||||
fixture.nativeDir,
|
||||
'lib',
|
||||
'libmpv.so.2'
|
||||
);
|
||||
unlinkSync(runtimePath);
|
||||
mkdirSync(runtimePath);
|
||||
},
|
||||
reason: 'runtime-library-invalid',
|
||||
},
|
||||
])('rejects a $label', ({ mutate, reason }) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
mutate(fixture);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason,
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])(
|
||||
'rejects unsafe runtime path %s',
|
||||
(unsafeName) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const runtimeFiles = manifest.runtimeFiles as RuntimeFile[];
|
||||
runtimeFiles[0].name = unsafeName;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('uses read and execute access checks for declared artifacts', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
const accessMock = context.fileSystem.accessSync as jest.Mock;
|
||||
expect(accessMock).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
fsConstants.R_OK | fsConstants.X_OK
|
||||
);
|
||||
expect(accessMock).toHaveBeenCalledWith(
|
||||
path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
fsConstants.R_OK
|
||||
);
|
||||
});
|
||||
});
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { createHash } from 'crypto';
|
||||
import * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
FRAME_COPY_ADDON_NAME,
|
||||
FRAME_COPY_HELPER_NAME,
|
||||
FRAME_COPY_READER_NAME,
|
||||
} from './contracts';
|
||||
import {
|
||||
validateDevelopmentManifest,
|
||||
validatePackagedManifest,
|
||||
} from './manifest-validator';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
RuntimeFile,
|
||||
ValidatedPackage,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
import {
|
||||
isMissingFileError,
|
||||
isValidationFailure,
|
||||
readManifest,
|
||||
validationFailure,
|
||||
} from './validation-primitives';
|
||||
|
||||
function validateRegularArtifact(
|
||||
filePath: string,
|
||||
accessMode: number,
|
||||
expectedMode: number | null,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): ValidationResult<nodeFileSystem.Stats> {
|
||||
let stat: nodeFileSystem.Stats;
|
||||
try {
|
||||
stat = fileSystem.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
return validationFailure(
|
||||
isMissingFileError(error)
|
||||
? 'runtime-artifact-missing'
|
||||
: 'runtime-artifact-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
stat.isSymbolicLink() ||
|
||||
!stat.isFile() ||
|
||||
(expectedMode !== null && (stat.mode & 0o7777) !== expectedMode)
|
||||
) {
|
||||
return validationFailure('runtime-artifact-invalid');
|
||||
}
|
||||
try {
|
||||
fileSystem.accessSync(filePath, accessMode);
|
||||
} catch {
|
||||
return validationFailure('runtime-artifact-invalid');
|
||||
}
|
||||
return { value: stat };
|
||||
}
|
||||
|
||||
function pathExistsByLstat(
|
||||
filePath: string,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): boolean {
|
||||
try {
|
||||
fileSystem.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBundledRuntimeFiles(
|
||||
nativeDir: string,
|
||||
runtimeFiles: RuntimeFile[],
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): ValidationResult<true> {
|
||||
const libDir = path.join(nativeDir, 'lib');
|
||||
let libStat: nodeFileSystem.Stats;
|
||||
try {
|
||||
libStat = fileSystem.lstatSync(libDir);
|
||||
} catch {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
if (libStat.isSymbolicLink() || !libStat.isDirectory()) {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
|
||||
let packagedNames: string[];
|
||||
try {
|
||||
packagedNames = fileSystem.readdirSync(libDir) as string[];
|
||||
} catch {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
const declaredNames = new Set(runtimeFiles.map(({ name }) => name));
|
||||
if (packagedNames.some((name) => !declaredNames.has(name))) {
|
||||
return validationFailure('runtime-library-undeclared');
|
||||
}
|
||||
|
||||
for (const runtimeFile of runtimeFiles) {
|
||||
const runtimePath = path.join(libDir, runtimeFile.name);
|
||||
let stat: nodeFileSystem.Stats;
|
||||
try {
|
||||
stat = fileSystem.lstatSync(runtimePath);
|
||||
} catch (error) {
|
||||
return validationFailure(
|
||||
isMissingFileError(error)
|
||||
? 'runtime-library-missing'
|
||||
: 'runtime-library-invalid'
|
||||
);
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
return validationFailure('runtime-library-invalid');
|
||||
}
|
||||
try {
|
||||
fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK);
|
||||
} catch {
|
||||
return validationFailure('runtime-library-invalid');
|
||||
}
|
||||
if (stat.size !== runtimeFile.size) {
|
||||
return validationFailure('runtime-library-size-mismatch');
|
||||
}
|
||||
|
||||
let contents: Buffer;
|
||||
try {
|
||||
contents = fileSystem.readFileSync(runtimePath);
|
||||
} catch {
|
||||
return validationFailure('runtime-library-invalid');
|
||||
}
|
||||
if (contents.length !== runtimeFile.size) {
|
||||
return validationFailure('runtime-library-size-mismatch');
|
||||
}
|
||||
const actualSha256 = createHash('sha256')
|
||||
.update(contents)
|
||||
.digest('hex');
|
||||
if (actualSha256 !== runtimeFile.sha256) {
|
||||
return validationFailure('runtime-library-hash-mismatch');
|
||||
}
|
||||
}
|
||||
return { value: true };
|
||||
}
|
||||
|
||||
export function validatePackage(
|
||||
helperPath: string,
|
||||
manifestPath: string,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract
|
||||
): ValidationResult<ValidatedPackage> {
|
||||
const nativeDir = path.dirname(helperPath);
|
||||
if (
|
||||
path.basename(helperPath) !== FRAME_COPY_HELPER_NAME ||
|
||||
path.dirname(manifestPath) !== nativeDir
|
||||
) {
|
||||
return validationFailure('runtime-artifact-invalid');
|
||||
}
|
||||
|
||||
const manifestArtifact = validateRegularArtifact(
|
||||
manifestPath,
|
||||
nodeFileSystem.constants.R_OK,
|
||||
0o644,
|
||||
fileSystem
|
||||
);
|
||||
if (isValidationFailure(manifestArtifact)) {
|
||||
return validationFailure(
|
||||
manifestArtifact.reason === 'runtime-artifact-missing'
|
||||
? 'runtime-manifest-missing'
|
||||
: 'runtime-manifest-invalid'
|
||||
);
|
||||
}
|
||||
const manifestResult = readManifest(manifestPath, fileSystem);
|
||||
if (isValidationFailure(manifestResult)) {
|
||||
return manifestResult;
|
||||
}
|
||||
const validManifest =
|
||||
manifestContract === 'packaged'
|
||||
? validatePackagedManifest(manifestResult.value)
|
||||
: validateDevelopmentManifest(manifestResult.value);
|
||||
if (isValidationFailure(validManifest)) {
|
||||
return validManifest;
|
||||
}
|
||||
|
||||
for (const [artifactPath, accessMode, expectedMode] of [
|
||||
[
|
||||
path.join(nativeDir, FRAME_COPY_ADDON_NAME),
|
||||
nodeFileSystem.constants.R_OK,
|
||||
null,
|
||||
],
|
||||
[
|
||||
path.join(nativeDir, FRAME_COPY_READER_NAME),
|
||||
nodeFileSystem.constants.R_OK,
|
||||
0o644,
|
||||
],
|
||||
[
|
||||
helperPath,
|
||||
nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK,
|
||||
0o755,
|
||||
],
|
||||
] as const) {
|
||||
const artifact = validateRegularArtifact(
|
||||
artifactPath,
|
||||
accessMode,
|
||||
expectedMode,
|
||||
fileSystem
|
||||
);
|
||||
if (isValidationFailure(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
}
|
||||
|
||||
const libDir = path.join(nativeDir, 'lib');
|
||||
if (validManifest.value.runtimeMode === 'system') {
|
||||
try {
|
||||
if (pathExistsByLstat(libDir, fileSystem)) {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
} catch {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
} else {
|
||||
const bundledRuntime = validateBundledRuntimeFiles(
|
||||
nativeDir,
|
||||
validManifest.value.runtimeFiles,
|
||||
fileSystem
|
||||
);
|
||||
if (isValidationFailure(bundledRuntime)) {
|
||||
return bundledRuntime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
manifest: validManifest.value,
|
||||
helperPath,
|
||||
nativeDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy runtime probe orchestration', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
LIBGL_ALWAYS_SOFTWARE: '1',
|
||||
GALLIUM_DRIVER: 'llvmpipe',
|
||||
BASH_ENV: '/tmp/hostile-bash-env',
|
||||
ENV: '/tmp/hostile-shell-env',
|
||||
BASHOPTS: 'extdebug',
|
||||
SHELLOPTS: 'xtrace',
|
||||
PS4: '$(/tmp/hostile-trace-hook)',
|
||||
BASH_XTRACEFD: '9',
|
||||
CDPATH: '/tmp/hostile-cdpath',
|
||||
'BASH_FUNC_dirname%%':
|
||||
'() { printf /tmp/hostile-provider-root; exit 0; }',
|
||||
LD_AUDIT: '/tmp/audit.so',
|
||||
LD_LIBRARY_PATH: '/ambient/libs',
|
||||
LD_ORIGIN_PATH: '/tmp/hostile-origin',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
|
||||
'/tmp/hostile-egl-platform.json',
|
||||
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
|
||||
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
|
||||
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
|
||||
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
|
||||
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
|
||||
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
|
||||
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
|
||||
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
|
||||
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
|
||||
VK_ADD_IMPLICIT_LAYER_PATH:
|
||||
'/tmp/hostile-vulkan-add-implicit-layers',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
|
||||
},
|
||||
});
|
||||
|
||||
expect(probeRuntime(fixture.helperPath)).toEqual({
|
||||
usable: true,
|
||||
profile: 'system',
|
||||
runtimeMode: 'system',
|
||||
libmpv: '2.3',
|
||||
renderApi: 'egl',
|
||||
});
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
['--runtime-probe'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
killSignal: 'SIGKILL',
|
||||
windowsHide: true,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
LIBGL_ALWAYS_SOFTWARE: '1',
|
||||
GALLIUM_DRIVER: 'llvmpipe',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(context.fileSystem.readFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates a cached result when helper or manifest bytes change under identical stats', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const fixedStats = new Map([
|
||||
[fixture.helperPath, lstatSync(fixture.helperPath)],
|
||||
[fixture.manifestPath, lstatSync(fixture.manifestPath)],
|
||||
]);
|
||||
context.fileSystem = {
|
||||
...context.fileSystem,
|
||||
lstatSync: jest.fn(
|
||||
(filePath: string) =>
|
||||
fixedStats.get(filePath) ?? lstatSync(filePath)
|
||||
),
|
||||
};
|
||||
const probeRuntime = context.createProbe();
|
||||
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
const changedHelper = readFileSync(fixture.helperPath);
|
||||
changedHelper[0] ^= 0xff;
|
||||
writeFileSync(fixture.helperPath, changedHelper);
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
fixture.manifest.generatedAt = '2026-07-18T00:00:00.000Z';
|
||||
writeManifest(fixture.manifestPath, fixture.manifest);
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(['portable', 'flatpak'] as const)(
|
||||
'validates the exact %s bundled closure and uses only its private library directory',
|
||||
(profile) => {
|
||||
const fixture = createFixture(context.rootDir, profile);
|
||||
const probeRuntime = context.createProbe();
|
||||
|
||||
expect(probeRuntime(fixture.helperPath)).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
profile,
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
['--runtime-probe'],
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('runs a packaged Snap probe through the connected graphics provider wrapper', () => {
|
||||
const actualSnapRoot = path.join(context.rootDir, 'snap-root');
|
||||
const actualFixtureRoot = path.join(actualSnapRoot, 'fixture');
|
||||
const fixture = createFixture(actualFixtureRoot, 'portable');
|
||||
const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics');
|
||||
const actualProviderWrapper = path.join(
|
||||
actualGraphicsRoot,
|
||||
'bin',
|
||||
'graphics-core22-provider-wrapper'
|
||||
);
|
||||
mkdirSync(path.dirname(actualProviderWrapper), { recursive: true });
|
||||
writeFileSync(actualProviderWrapper, '#!/bin/sh\nexec "$@"\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const virtualSnapRoot = '/snap/iptvnator/42';
|
||||
const linuxTriplet = 'x86_64-linux-gnu';
|
||||
const snapLibraries = (...relativePaths: string[]): string[] =>
|
||||
relativePaths.map((relativePath) =>
|
||||
path.join(virtualSnapRoot, relativePath)
|
||||
);
|
||||
const virtualNativeDir = path.join(
|
||||
virtualSnapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const virtualHelperPath = path.join(
|
||||
virtualNativeDir,
|
||||
'iptvnator_mpv_helper'
|
||||
);
|
||||
const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics');
|
||||
const virtualProviderWrapper = path.join(
|
||||
virtualGraphicsRoot,
|
||||
'bin',
|
||||
'graphics-core22-provider-wrapper'
|
||||
);
|
||||
const translatePath = (candidatePath: string): string => {
|
||||
if (
|
||||
candidatePath === virtualNativeDir ||
|
||||
candidatePath.startsWith(`${virtualNativeDir}${path.sep}`)
|
||||
) {
|
||||
return path.join(
|
||||
fixture.nativeDir,
|
||||
path.relative(virtualNativeDir, candidatePath)
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidatePath === virtualGraphicsRoot ||
|
||||
candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`)
|
||||
) {
|
||||
return path.join(
|
||||
actualGraphicsRoot,
|
||||
path.relative(virtualGraphicsRoot, candidatePath)
|
||||
);
|
||||
}
|
||||
return candidatePath;
|
||||
};
|
||||
const virtualFileSystem = {
|
||||
accessSync: jest.fn((candidatePath: string, mode: number) =>
|
||||
accessSync(translatePath(candidatePath), mode)
|
||||
),
|
||||
lstatSync: jest.fn((candidatePath: string) =>
|
||||
lstatSync(translatePath(candidatePath))
|
||||
),
|
||||
readFileSync: jest.fn((candidatePath: string) =>
|
||||
readFileSync(translatePath(candidatePath))
|
||||
),
|
||||
readdirSync: jest.fn((candidatePath: string) =>
|
||||
readdirSync(translatePath(candidatePath))
|
||||
),
|
||||
};
|
||||
const probeRuntime = context.createProbe({
|
||||
env: {
|
||||
PATH: '/snap/bin:/usr/bin',
|
||||
SNAP: virtualSnapRoot,
|
||||
SNAP_DESKTOP_RUNTIME: path.join(
|
||||
virtualSnapRoot,
|
||||
'gnome-platform'
|
||||
),
|
||||
SNAP_LIBRARY_PATH:
|
||||
'/var/lib/snapd/lib/gl:/var/lib/snapd/lib/gl/nvidia',
|
||||
},
|
||||
fileSystem: virtualFileSystem,
|
||||
});
|
||||
|
||||
expect(probeRuntime(virtualHelperPath)).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
profile: 'portable',
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
virtualProviderWrapper,
|
||||
[virtualHelperPath, '--runtime-probe'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
SNAP: virtualSnapRoot,
|
||||
LD_LIBRARY_PATH: [
|
||||
path.join(virtualNativeDir, 'lib'),
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
...snapLibraries(
|
||||
`graphics/usr/lib/${linuxTriplet}`,
|
||||
`graphics/usr/lib/${linuxTriplet}/vdpau`
|
||||
),
|
||||
'/usr/lib/x86_64-linux-gnu',
|
||||
...snapLibraries(
|
||||
`gnome-platform/lib/${linuxTriplet}`,
|
||||
`gnome-platform/usr/lib/${linuxTriplet}`,
|
||||
`gnome-platform/usr/lib/${linuxTriplet}/mesa`,
|
||||
`gnome-platform/usr/lib/${linuxTriplet}/mesa-egl`,
|
||||
`gnome-platform/usr/lib/${linuxTriplet}/dri`,
|
||||
`gnome-platform/usr/lib/${linuxTriplet}/pulseaudio`,
|
||||
'lib',
|
||||
'usr/lib',
|
||||
`lib/${linuxTriplet}`,
|
||||
`usr/lib/${linuxTriplet}`
|
||||
),
|
||||
].join(':'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a stable unavailable reason when the Snap graphics provider is disconnected', () => {
|
||||
const actualSnapRoot = path.join(context.rootDir, 'snap-root');
|
||||
const fixture = createFixture(
|
||||
path.join(actualSnapRoot, 'fixture'),
|
||||
'portable'
|
||||
);
|
||||
const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics');
|
||||
mkdirSync(actualGraphicsRoot, { recursive: true });
|
||||
|
||||
const virtualSnapRoot = '/snap/iptvnator/42';
|
||||
const virtualNativeDir = path.join(
|
||||
virtualSnapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const translatePath = (candidatePath: string): string => {
|
||||
if (
|
||||
candidatePath === virtualNativeDir ||
|
||||
candidatePath.startsWith(`${virtualNativeDir}${path.sep}`)
|
||||
) {
|
||||
return path.join(
|
||||
fixture.nativeDir,
|
||||
path.relative(virtualNativeDir, candidatePath)
|
||||
);
|
||||
}
|
||||
const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics');
|
||||
if (
|
||||
candidatePath === virtualGraphicsRoot ||
|
||||
candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`)
|
||||
) {
|
||||
return path.join(
|
||||
actualGraphicsRoot,
|
||||
path.relative(virtualGraphicsRoot, candidatePath)
|
||||
);
|
||||
}
|
||||
return candidatePath;
|
||||
};
|
||||
const probeRuntime = context.createProbe({
|
||||
env: { SNAP: virtualSnapRoot },
|
||||
fileSystem: {
|
||||
accessSync: (candidatePath, mode) =>
|
||||
accessSync(translatePath(candidatePath), mode),
|
||||
lstatSync: (candidatePath) =>
|
||||
lstatSync(translatePath(candidatePath)),
|
||||
readFileSync: (candidatePath) =>
|
||||
readFileSync(translatePath(candidatePath)),
|
||||
readdirSync: (candidatePath) =>
|
||||
readdirSync(translatePath(candidatePath)),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
probeRuntime(path.join(virtualNativeDir, 'iptvnator_mpv_helper'))
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'snap-graphics-provider-unavailable',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reprobes when the helper identity changes', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n');
|
||||
chmodSync(fixture.helperPath, 0o755);
|
||||
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reprobes when the manifest identity changes', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest.generatedAt = '2026-07-17T00:01:00.000Z';
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['linux', 'arm64', 'unsupported-architecture'],
|
||||
['linux', 'arm', 'unsupported-architecture'],
|
||||
['darwin', 'x64', 'unsupported-platform'],
|
||||
] as const)(
|
||||
'does not probe unsupported %s/%s runtimes',
|
||||
(platform, arch, reason) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
|
||||
expect(
|
||||
context.createProbe({ platform, arch })(fixture.helperPath)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason,
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { spawnSync as nodeSpawnSync } from 'child_process';
|
||||
import * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
RUNTIME_MANIFEST_NAME,
|
||||
RUNTIME_PROBE_MAX_BUFFER_BYTES,
|
||||
RUNTIME_PROBE_PROTOCOL,
|
||||
RUNTIME_PROBE_TIMEOUT_MS,
|
||||
} from './contracts';
|
||||
import { createLinuxFrameCopyHelperLaunch } from './helper-launch';
|
||||
import { validatePackage } from './package-validator';
|
||||
import { EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS } from './types';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeDependencies,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
EmbeddedMpvHelperRuntimeProbeFailureReason,
|
||||
ValidManifest,
|
||||
ValidatedPackage,
|
||||
} from './types';
|
||||
import {
|
||||
failure,
|
||||
fileIdentity,
|
||||
hasExactFields,
|
||||
isMissingFileError,
|
||||
isObject,
|
||||
isValidationFailure,
|
||||
} from './validation-primitives';
|
||||
|
||||
const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set<string>(
|
||||
Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)
|
||||
);
|
||||
const HELPER_RUNTIME_PROBE_STDERR_LIMIT = 16_384;
|
||||
const HELPER_RUNTIME_PROBE_STDERR_EVENT =
|
||||
'embedded-mpv-helper-runtime-probe-stderr';
|
||||
|
||||
interface ParsedFailedProbe {
|
||||
helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason;
|
||||
helperDetail?: string;
|
||||
}
|
||||
|
||||
function isSafeHelperDetail(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 1024 &&
|
||||
!/[^\x20-\x7e]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function parseFailedProbe(stdout: unknown): ParsedFailedProbe | null {
|
||||
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout.slice(0, -1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isObject(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasDetail = Object.prototype.hasOwnProperty.call(parsed, 'detail');
|
||||
const fields = hasDetail
|
||||
? ['detail', 'protocol', 'reason', 'usable']
|
||||
: ['protocol', 'reason', 'usable'];
|
||||
if (
|
||||
!hasExactFields(parsed, fields) ||
|
||||
parsed.protocol !== RUNTIME_PROBE_PROTOCOL ||
|
||||
parsed.usable !== false ||
|
||||
typeof parsed.reason !== 'string' ||
|
||||
!HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) ||
|
||||
(hasDetail && !isSafeHelperDetail(parsed.detail))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
helperReason:
|
||||
parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason,
|
||||
...(hasDetail ? { helperDetail: parsed.detail as string } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSuccessfulProbe(
|
||||
stdout: unknown,
|
||||
manifest: ValidManifest
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout.slice(0, -1));
|
||||
} catch {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
if (!isObject(parsed)) {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) {
|
||||
return failure('helper-probe-protocol-mismatch');
|
||||
}
|
||||
if (parsed.usable !== true) {
|
||||
return failure(
|
||||
parsed.usable === false
|
||||
? 'helper-probe-unusable'
|
||||
: 'helper-probe-invalid-output'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!hasExactFields(parsed, [
|
||||
'libmpv',
|
||||
'protocol',
|
||||
'renderApi',
|
||||
'usable',
|
||||
]) ||
|
||||
typeof parsed.libmpv !== 'string' ||
|
||||
parsed.libmpv.trim() === '' ||
|
||||
parsed.renderApi !== 'egl'
|
||||
) {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
return {
|
||||
usable: true,
|
||||
profile: manifest.profile,
|
||||
runtimeMode: manifest.runtimeMode,
|
||||
libmpv: parsed.libmpv,
|
||||
renderApi: parsed.renderApi,
|
||||
};
|
||||
}
|
||||
|
||||
function traceHelperProbeStderr(
|
||||
stderr: unknown,
|
||||
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
|
||||
): void {
|
||||
if (
|
||||
dependencies.env.IPTVNATOR_TRACE_PLAYER !== '1' ||
|
||||
typeof stderr !== 'string' ||
|
||||
stderr.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedStderr = stderr.slice(0, HELPER_RUNTIME_PROBE_STDERR_LIMIT);
|
||||
const output = `${JSON.stringify({
|
||||
event: HELPER_RUNTIME_PROBE_STDERR_EVENT,
|
||||
stderr: boundedStderr,
|
||||
truncated: stderr.length > boundedStderr.length,
|
||||
})}\n`;
|
||||
try {
|
||||
dependencies.writeStderr(output);
|
||||
} catch {
|
||||
// Opt-in diagnostics must never change the fail-closed probe result.
|
||||
}
|
||||
}
|
||||
|
||||
function runHelperProbe(
|
||||
runtimePackage: ValidatedPackage,
|
||||
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
const launch = createLinuxFrameCopyHelperLaunch({
|
||||
environment: dependencies.env,
|
||||
helperPath: runtimePackage.helperPath,
|
||||
helperArgs: ['--runtime-probe'],
|
||||
runtimeMode: runtimePackage.manifest.runtimeMode,
|
||||
fileSystem: dependencies.fileSystem,
|
||||
});
|
||||
if (launch.usable === false) {
|
||||
return failure(launch.reason);
|
||||
}
|
||||
|
||||
let result: ReturnType<typeof nodeSpawnSync>;
|
||||
try {
|
||||
result = dependencies.spawnSync(launch.command, launch.args, {
|
||||
encoding: 'utf8',
|
||||
timeout: RUNTIME_PROBE_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
windowsHide: true,
|
||||
maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES,
|
||||
env: launch.env,
|
||||
});
|
||||
} catch {
|
||||
return failure('helper-probe-spawn-error');
|
||||
}
|
||||
traceHelperProbeStderr(result.stderr, dependencies);
|
||||
|
||||
if (
|
||||
result.error &&
|
||||
'code' in result.error &&
|
||||
result.error.code === 'ETIMEDOUT'
|
||||
) {
|
||||
return failure('helper-probe-timeout');
|
||||
}
|
||||
if (result.error) {
|
||||
return failure('helper-probe-spawn-error');
|
||||
}
|
||||
if (result.signal) {
|
||||
return failure('helper-probe-signaled');
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const helperFailure = parseFailedProbe(result.stdout);
|
||||
return helperFailure
|
||||
? {
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
...helperFailure,
|
||||
}
|
||||
: failure('helper-probe-failed');
|
||||
}
|
||||
return parseSuccessfulProbe(result.stdout, runtimePackage.manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated probe/cache. Production uses the singleton below;
|
||||
* tests inject filesystem and spawn collaborators through this factory.
|
||||
*/
|
||||
export function createEmbeddedMpvFrameCopyRuntimeProbe(
|
||||
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
|
||||
): (
|
||||
helperPath: string,
|
||||
manifestContract?: EmbeddedMpvFrameCopyManifestContract
|
||||
) => EmbeddedMpvFrameCopyRuntimeResult {
|
||||
const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = {
|
||||
accessSync: (filePath, mode) =>
|
||||
nodeFileSystem.accessSync(filePath, mode),
|
||||
lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath),
|
||||
readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath),
|
||||
readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath),
|
||||
};
|
||||
const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
env: process.env,
|
||||
fileSystem: defaultFileSystem,
|
||||
spawnSync: nodeSpawnSync,
|
||||
writeStderr: (output) => {
|
||||
nodeFileSystem.writeSync(process.stderr.fd, output);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
const resultCache = new Map<string, EmbeddedMpvFrameCopyRuntimeResult>();
|
||||
|
||||
return (
|
||||
helperPath: string,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged'
|
||||
): EmbeddedMpvFrameCopyRuntimeResult => {
|
||||
if (dependencies.platform !== 'linux') {
|
||||
return failure('unsupported-platform');
|
||||
}
|
||||
if (dependencies.arch !== 'x64') {
|
||||
return failure('unsupported-architecture');
|
||||
}
|
||||
|
||||
const manifestPath = path.join(
|
||||
path.dirname(helperPath),
|
||||
RUNTIME_MANIFEST_NAME
|
||||
);
|
||||
let helperStat: nodeFileSystem.Stats;
|
||||
let manifestStat: nodeFileSystem.Stats;
|
||||
try {
|
||||
helperStat = dependencies.fileSystem.lstatSync(helperPath);
|
||||
} catch {
|
||||
return failure('runtime-artifact-missing');
|
||||
}
|
||||
try {
|
||||
manifestStat = dependencies.fileSystem.lstatSync(manifestPath);
|
||||
} catch (error) {
|
||||
return failure(
|
||||
isMissingFileError(error)
|
||||
? 'runtime-manifest-missing'
|
||||
: 'runtime-manifest-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
let helperContents: Buffer;
|
||||
let manifestContents: Buffer;
|
||||
try {
|
||||
helperContents = dependencies.fileSystem.readFileSync(helperPath);
|
||||
} catch {
|
||||
return failure('runtime-artifact-invalid');
|
||||
}
|
||||
try {
|
||||
manifestContents =
|
||||
dependencies.fileSystem.readFileSync(manifestPath);
|
||||
} catch {
|
||||
return failure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
const cacheKey = `${manifestContract}\0${fileIdentity(
|
||||
helperPath,
|
||||
helperStat,
|
||||
helperContents
|
||||
)}\0${fileIdentity(manifestPath, manifestStat, manifestContents)}`;
|
||||
const cached = resultCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result: EmbeddedMpvFrameCopyRuntimeResult;
|
||||
try {
|
||||
const runtimePackage = validatePackage(
|
||||
helperPath,
|
||||
manifestPath,
|
||||
dependencies.fileSystem,
|
||||
manifestContract
|
||||
);
|
||||
result = isValidationFailure(runtimePackage)
|
||||
? failure(runtimePackage.reason)
|
||||
: runHelperProbe(runtimePackage.value, dependencies);
|
||||
} catch {
|
||||
result = failure('runtime-probe-internal-error');
|
||||
}
|
||||
resultCache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe();
|
||||
|
||||
/**
|
||||
* Fail-closed, process-lifetime Linux runtime decision shared by startup and
|
||||
* the service gate. The helper and manifest identities scope cached results.
|
||||
*/
|
||||
export function probeEmbeddedMpvFrameCopyRuntime(
|
||||
helperPath: string,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
return processRuntimeProbe(helperPath, manifestContract);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
ALLOWED_EXTERNAL_LIBRARY_NAMES,
|
||||
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES,
|
||||
SAFE_RUNTIME_NAME_PATTERN,
|
||||
SHARED_LIBRARY_PATTERN,
|
||||
} from './contracts';
|
||||
import type { RuntimeFile } from './types';
|
||||
import {
|
||||
hasExactFields,
|
||||
isObject,
|
||||
isSafeRuntimeName,
|
||||
} from './validation-primitives';
|
||||
|
||||
export function validateRuntimeClosure(
|
||||
value: unknown,
|
||||
runtimeFiles: RuntimeFile[],
|
||||
libmpvSoname: string,
|
||||
externalSystemLibraries: unknown
|
||||
): boolean {
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
externalSystemLibraries,
|
||||
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES
|
||||
) ||
|
||||
!isObject(value) ||
|
||||
!hasExactFields(value, ['entries', 'externalDependencies']) ||
|
||||
!Array.isArray(value.entries) ||
|
||||
!Array.isArray(value.externalDependencies) ||
|
||||
value.externalDependencies.some(
|
||||
(dependency) =>
|
||||
typeof dependency !== 'string' ||
|
||||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
|
||||
!SHARED_LIBRARY_PATTERN.test(dependency)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const runtimeNames = runtimeFiles.map(({ name }) => name);
|
||||
const runtimeNameSet = new Set(runtimeNames);
|
||||
const closureNames: string[] = [];
|
||||
const computedExternalDependencies = new Set<string>();
|
||||
for (const entry of value.entries) {
|
||||
if (
|
||||
!isObject(entry) ||
|
||||
!hasExactFields(entry, [
|
||||
'name',
|
||||
'needed',
|
||||
'rpath',
|
||||
'runpath',
|
||||
'soname',
|
||||
]) ||
|
||||
!isSafeRuntimeName(entry.name) ||
|
||||
(entry.soname !== null && !isSafeRuntimeName(entry.soname)) ||
|
||||
!Array.isArray(entry.needed) ||
|
||||
entry.needed.some(
|
||||
(dependency) =>
|
||||
typeof dependency !== 'string' ||
|
||||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
|
||||
!SHARED_LIBRARY_PATTERN.test(dependency)
|
||||
) ||
|
||||
!isDeepStrictEqual(entry.rpath, []) ||
|
||||
!isDeepStrictEqual(entry.runpath, ['$ORIGIN']) ||
|
||||
new Set(entry.needed).size !== entry.needed.length ||
|
||||
!isDeepStrictEqual([...entry.needed].sort(), entry.needed)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
closureNames.push(entry.name);
|
||||
for (const dependency of entry.needed) {
|
||||
if (runtimeNameSet.has(dependency)) {
|
||||
continue;
|
||||
}
|
||||
if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependency)) {
|
||||
return false;
|
||||
}
|
||||
computedExternalDependencies.add(dependency);
|
||||
}
|
||||
}
|
||||
|
||||
const linkerAlias = value.entries.find(
|
||||
(entry) => isObject(entry) && entry.name === 'libmpv.so'
|
||||
);
|
||||
return (
|
||||
isDeepStrictEqual(closureNames, runtimeNames) &&
|
||||
new Set(closureNames).size === closureNames.length &&
|
||||
isDeepStrictEqual(
|
||||
value.externalDependencies,
|
||||
[...computedExternalDependencies].sort()
|
||||
) &&
|
||||
isObject(linkerAlias) &&
|
||||
linkerAlias.soname === libmpvSoname
|
||||
);
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { createEmbeddedMpvFrameCopyRuntimeProbe } from '../embedded-mpv-frame-copy-runtime';
|
||||
import {
|
||||
EXTERNAL_SYSTEM_LIBRARIES,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES,
|
||||
type RuntimeFile,
|
||||
type RuntimeFixture,
|
||||
} from './runtime.spec-data';
|
||||
|
||||
function sha256(contents: Buffer): string {
|
||||
return createHash('sha256').update(contents).digest('hex');
|
||||
}
|
||||
|
||||
function createRuntimeFiles(
|
||||
runtimeContents: Record<string, Buffer>
|
||||
): RuntimeFile[] {
|
||||
return Object.entries(runtimeContents)
|
||||
.map(([name, contents]) => ({
|
||||
name,
|
||||
size: contents.length,
|
||||
sha256: sha256(contents),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function createSourceRuntime(
|
||||
runtimeFiles: RuntimeFile[],
|
||||
runtimeDependencyClosure: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
origin: 'vendored-lgpl-source-build',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
packages,
|
||||
ffmpeg: {
|
||||
...(packages.ffmpeg as Record<string, unknown>),
|
||||
configureFlags: ['--disable-gpl', '--disable-nonfree'],
|
||||
},
|
||||
mpv: {
|
||||
...(packages.mpv as Record<string, unknown>),
|
||||
mesonFlags: ['-Dgpl=false', '-Dlibmpv=true'],
|
||||
},
|
||||
sourceDistribution:
|
||||
'Publish the exact hwdata archive and pnp.ids with the libdisplay-info source.',
|
||||
runtimeFiles,
|
||||
runtimeTotalBytes: runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
),
|
||||
runtimeAbi: {
|
||||
baseline: {
|
||||
distribution: 'Ubuntu 22.04',
|
||||
glibcMaximum: '2.35',
|
||||
glibcxxMaximum: '3.4.30',
|
||||
},
|
||||
files: runtimeFiles.map(({ name }) => ({
|
||||
name,
|
||||
requiredGlibc: '2.34',
|
||||
requiredGlibcxx: null,
|
||||
})),
|
||||
},
|
||||
runtimeDependencyClosure,
|
||||
externalSystemLibraries: cloneManifest(EXTERNAL_SYSTEM_LIBRARIES),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFixture(
|
||||
rootDir: string,
|
||||
profile: 'system' | 'portable' | 'flatpak' = 'system'
|
||||
): RuntimeFixture {
|
||||
const nativeDir = path.join(rootDir, profile, 'native');
|
||||
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
|
||||
const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json');
|
||||
mkdirSync(nativeDir, { recursive: true });
|
||||
writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', {
|
||||
mode: 0o644,
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
'reader',
|
||||
{ mode: 0o644 }
|
||||
);
|
||||
writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
|
||||
const bundled = profile !== 'system';
|
||||
const runtimeContents = bundled
|
||||
? {
|
||||
'libmpv.so': Buffer.from('libmpv-linker-alias'),
|
||||
'libmpv.so.2': Buffer.from('libmpv-soname'),
|
||||
}
|
||||
: {};
|
||||
const runtimeFiles = createRuntimeFiles(runtimeContents);
|
||||
const runtimeDependencyClosure = {
|
||||
entries: runtimeFiles.map(({ name }) => ({
|
||||
name,
|
||||
soname: name === 'libmpv.so' ? 'libmpv.so.2' : name,
|
||||
needed: [],
|
||||
rpath: [],
|
||||
runpath: ['$ORIGIN'],
|
||||
})),
|
||||
externalDependencies: [],
|
||||
};
|
||||
const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES);
|
||||
const sourceRuntime = createSourceRuntime(
|
||||
runtimeFiles,
|
||||
runtimeDependencyClosure
|
||||
);
|
||||
const sourceArchive = {
|
||||
schemaVersion: 1,
|
||||
name: 'linux-frame-copy-runtime-sources.tar.xz',
|
||||
sha256: '7'.repeat(64),
|
||||
repositoryRevision: '8'.repeat(40),
|
||||
};
|
||||
const manifest: Record<string, unknown> = {
|
||||
schemaVersion: 1,
|
||||
origin: bundled
|
||||
? 'bundled-lgpl-frame-copy'
|
||||
: 'system-libmpv-frame-copy',
|
||||
generatedAt: '2026-07-17T00:00:00.000Z',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
profile,
|
||||
runtimeMode: bundled ? 'bundled' : 'system',
|
||||
targets:
|
||||
profile === 'system'
|
||||
? ['deb', 'pacman', 'rpm']
|
||||
: profile === 'portable'
|
||||
? ['appimage', 'snap']
|
||||
: ['flatpak'],
|
||||
artifacts: {
|
||||
addon: {
|
||||
name: 'embedded_mpv.node',
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
frameReader: {
|
||||
name: 'embedded_mpv_frame_reader.node',
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
helper: {
|
||||
name: 'iptvnator_mpv_helper',
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
executable: true,
|
||||
},
|
||||
},
|
||||
processIsolation: {
|
||||
addonLoadsLibmpv: false,
|
||||
readerLoadsLibmpv: false,
|
||||
electronLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
},
|
||||
nativeViewFallback: 'process-isolated mpv --wid',
|
||||
libmpvSoname: 'libmpv.so.2',
|
||||
packageDependencies: bundled
|
||||
? {}
|
||||
: {
|
||||
deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'],
|
||||
rpm: [
|
||||
'mpv-libs',
|
||||
'libglvnd-egl',
|
||||
'libglvnd-glx',
|
||||
'mesa-libgbm',
|
||||
],
|
||||
pacman: ['mpv', 'libglvnd', 'mesa'],
|
||||
},
|
||||
runtimeFiles,
|
||||
runtimeTotalBytes: runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
),
|
||||
...(bundled
|
||||
? {
|
||||
runtimeDependencyClosure,
|
||||
externalSystemLibraries,
|
||||
sourceArchive,
|
||||
sourceRuntime,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
writeManifest(manifestPath, manifest);
|
||||
if (bundled) {
|
||||
const libDir = path.join(nativeDir, 'lib');
|
||||
mkdirSync(libDir);
|
||||
for (const [name, contents] of Object.entries(runtimeContents)) {
|
||||
writeFileSync(path.join(libDir, name), contents, {
|
||||
mode: 0o644,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
nativeDir,
|
||||
helperPath,
|
||||
manifestPath,
|
||||
manifest,
|
||||
runtimeContents,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDevelopmentFixture(
|
||||
rootDir: string,
|
||||
buildInputMode: 'system-dev' | 'system-build-inputs' | 'bundled-runtime'
|
||||
): RuntimeFixture {
|
||||
const bundled = buildInputMode === 'bundled-runtime';
|
||||
const fixture = createFixture(rootDir, bundled ? 'portable' : 'system');
|
||||
const packagedSourceRuntime = fixture.manifest.sourceRuntime;
|
||||
const sourceRuntime =
|
||||
buildInputMode === 'system-dev'
|
||||
? {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
warning:
|
||||
'Development-only unmanaged system libmpv toolchain.',
|
||||
}
|
||||
: buildInputMode === 'system-build-inputs'
|
||||
? {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
buildInputs: {
|
||||
libmpvDevPackage: 'libmpv-dev',
|
||||
mpvPackage: 'mpv',
|
||||
},
|
||||
sourceDistribution:
|
||||
'Linux development inputs are supplied by the host package manager.',
|
||||
}
|
||||
: packagedSourceRuntime;
|
||||
const manifest: Record<string, unknown> = {
|
||||
schemaVersion: 1,
|
||||
origin: 'linux-frame-copy-build',
|
||||
generatedAt: '2026-07-17T00:00:00.000Z',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
buildInputMode,
|
||||
sourceRuntimeValidated: bundled,
|
||||
allowedPackageRuntimeModes: ['system', 'bundled'],
|
||||
packageRuntimeAvailability: {
|
||||
system: bundled,
|
||||
bundled,
|
||||
},
|
||||
artifacts: {
|
||||
addon: 'embedded_mpv.node',
|
||||
frameReader: 'embedded_mpv_frame_reader.node',
|
||||
helper: 'iptvnator_mpv_helper',
|
||||
},
|
||||
processIsolation: {
|
||||
addonLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
},
|
||||
nativeViewFallback: 'process-isolated mpv --wid',
|
||||
libmpvSoname: bundled ? 'libmpv.so.2' : null,
|
||||
runtimeFiles: fixture.manifest.runtimeFiles,
|
||||
runtimeTotalBytes: fixture.manifest.runtimeTotalBytes,
|
||||
sourceArchive: bundled
|
||||
? cloneManifest(fixture.manifest.sourceArchive)
|
||||
: null,
|
||||
sourceRuntime,
|
||||
};
|
||||
fixture.manifest = manifest;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export function probeDevelopmentRuntime(
|
||||
probeRuntime: ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>,
|
||||
helperPath: string
|
||||
) {
|
||||
return (
|
||||
probeRuntime as unknown as (
|
||||
path: string,
|
||||
contract: 'development'
|
||||
) => ReturnType<typeof probeRuntime>
|
||||
)(helperPath, 'development');
|
||||
}
|
||||
|
||||
export function mirrorBundledManifestFields(
|
||||
manifest: Record<string, unknown>
|
||||
): void {
|
||||
const sourceRuntime = manifest.sourceRuntime as Record<string, unknown>;
|
||||
sourceRuntime.runtimeFiles = cloneManifest(manifest.runtimeFiles);
|
||||
sourceRuntime.runtimeTotalBytes = manifest.runtimeTotalBytes;
|
||||
sourceRuntime.runtimeDependencyClosure = cloneManifest(
|
||||
manifest.runtimeDependencyClosure
|
||||
);
|
||||
sourceRuntime.externalSystemLibraries = cloneManifest(
|
||||
manifest.externalSystemLibraries
|
||||
);
|
||||
}
|
||||
|
||||
export function writeManifest(
|
||||
manifestPath: string,
|
||||
manifest: Record<string, unknown>
|
||||
): void {
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
|
||||
mode: 0o644,
|
||||
});
|
||||
chmodSync(manifestPath, 0o644);
|
||||
}
|
||||
|
||||
export function cloneManifest<T>(manifest: T): T {
|
||||
return JSON.parse(JSON.stringify(manifest)) as T;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
createEmbeddedMpvFrameCopyRuntimeProbe,
|
||||
type EmbeddedMpvFrameCopyRuntimeDependencies,
|
||||
} from '../embedded-mpv-frame-copy-runtime';
|
||||
import { SUCCESS_OUTPUT } from './runtime.spec-data';
|
||||
|
||||
export interface RuntimeTestContext {
|
||||
rootDir: string;
|
||||
spawnRuntimeProbe: jest.Mock;
|
||||
writeRuntimeProbeStderr: jest.Mock<void, [string]>;
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'];
|
||||
createProbe(
|
||||
overrides?: Partial<EmbeddedMpvFrameCopyRuntimeDependencies>
|
||||
): ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createRuntimeTestContext(): RuntimeTestContext {
|
||||
const rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-'));
|
||||
const spawnRuntimeProbe = jest.fn(() => ({
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: SUCCESS_OUTPUT,
|
||||
stderr: '',
|
||||
}));
|
||||
const writeRuntimeProbeStderr = jest.fn<void, [string]>();
|
||||
const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = {
|
||||
accessSync: jest.fn((filePath: string, mode: number) =>
|
||||
accessSync(filePath, mode)
|
||||
),
|
||||
lstatSync: jest.fn((filePath: string) => lstatSync(filePath)),
|
||||
readFileSync: jest.fn((filePath: string) => readFileSync(filePath)),
|
||||
readdirSync: jest.fn((filePath: string) => readdirSync(filePath)),
|
||||
};
|
||||
const context: RuntimeTestContext = {
|
||||
rootDir,
|
||||
spawnRuntimeProbe,
|
||||
writeRuntimeProbeStderr,
|
||||
fileSystem,
|
||||
createProbe(
|
||||
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
|
||||
) {
|
||||
return createEmbeddedMpvFrameCopyRuntimeProbe({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
LD_AUDIT: '/tmp/audit.so',
|
||||
LD_LIBRARY_PATH: '/ambient/libs',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
},
|
||||
fileSystem: context.fileSystem,
|
||||
spawnSync: context.spawnRuntimeProbe as typeof spawnSync,
|
||||
writeStderr: context.writeRuntimeProbeStderr,
|
||||
...overrides,
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
rmSync(context.rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
return context;
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
export const SUCCESS_OUTPUT =
|
||||
'{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n';
|
||||
|
||||
export const EXTERNAL_SYSTEM_LIBRARIES = [
|
||||
{
|
||||
name: 'libEGL.so.1',
|
||||
interface: 'EGL',
|
||||
reason: 'System graphics-driver interface used by the frame-copy helper.',
|
||||
},
|
||||
{
|
||||
name: 'libGL.so.1',
|
||||
interface: 'OpenGL',
|
||||
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libGLX.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libOpenGL.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libasound.so.2',
|
||||
interface: 'ALSA',
|
||||
reason: 'Linux system audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libdrm.so.2',
|
||||
interface: 'DRM',
|
||||
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
|
||||
},
|
||||
{
|
||||
name: 'libgbm.so.1',
|
||||
interface: 'GBM',
|
||||
reason: 'System graphics-buffer interface used by headless EGL rendering.',
|
||||
},
|
||||
{
|
||||
name: 'libpulse.so.0',
|
||||
interface: 'PulseAudio',
|
||||
reason: 'Linux desktop audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libva-drm.so.2',
|
||||
interface: 'VA-API DRM',
|
||||
reason: 'System VA-API DRM interface used for hardware decoding.',
|
||||
},
|
||||
{
|
||||
name: 'libva.so.2',
|
||||
interface: 'VA-API',
|
||||
reason: 'System video-acceleration interface used for hardware decoding.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
|
||||
freetype: {
|
||||
version: '2.13.3',
|
||||
sourceUrl:
|
||||
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
|
||||
sourceSha256:
|
||||
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
|
||||
license: 'FreeType License (FTL)',
|
||||
},
|
||||
fribidi: {
|
||||
version: '1.0.16',
|
||||
sourceUrl:
|
||||
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
|
||||
sourceSha256:
|
||||
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
harfbuzz: {
|
||||
version: '8.5.0',
|
||||
sourceUrl:
|
||||
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
|
||||
sourceSha256:
|
||||
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
|
||||
license: 'MIT',
|
||||
},
|
||||
expat: {
|
||||
version: '2.8.2',
|
||||
sourceUrl:
|
||||
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
|
||||
sourceSha256:
|
||||
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
|
||||
license: 'MIT',
|
||||
},
|
||||
fontconfig: {
|
||||
version: '2.16.0',
|
||||
sourceUrl:
|
||||
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
|
||||
sourceSha256:
|
||||
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
|
||||
license: 'MIT',
|
||||
},
|
||||
libass: {
|
||||
version: '0.17.3',
|
||||
sourceUrl:
|
||||
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
|
||||
sourceSha256:
|
||||
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
|
||||
license: 'ISC',
|
||||
},
|
||||
openssl: {
|
||||
version: '3.5.7',
|
||||
sourceUrl:
|
||||
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
|
||||
sourceSha256:
|
||||
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
ffmpeg: {
|
||||
version: '8.1',
|
||||
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
|
||||
sourceSha256:
|
||||
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
libplacebo: {
|
||||
version: '7.360.1',
|
||||
sourceUrl: 'https://github.com/haasn/libplacebo.git',
|
||||
sourceTag: 'v7.360.1',
|
||||
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
|
||||
sourceSubmodules: [
|
||||
'450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers',
|
||||
'97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float',
|
||||
'73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad',
|
||||
'15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja',
|
||||
'297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe',
|
||||
'242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear',
|
||||
],
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
hwdata: {
|
||||
version: '0.409',
|
||||
sourceUrl:
|
||||
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
|
||||
sourceSha256:
|
||||
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
|
||||
buildInput: {
|
||||
consumer: 'libdisplay-info',
|
||||
relativePath: 'pnp.ids',
|
||||
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
|
||||
},
|
||||
license: 'GPL-2.0-or-later OR XFree86-1.0',
|
||||
},
|
||||
'libdisplay-info': {
|
||||
version: '0.1.1',
|
||||
sourceUrl:
|
||||
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
|
||||
sourceSha256:
|
||||
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
|
||||
license: 'MIT',
|
||||
},
|
||||
mpv: {
|
||||
version: '0.41.0',
|
||||
sourceUrl:
|
||||
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
|
||||
sourceSha256:
|
||||
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
|
||||
license: 'LGPL-2.1-or-later with -Dgpl=false',
|
||||
},
|
||||
};
|
||||
|
||||
export interface RuntimeFile {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface RuntimeFixture {
|
||||
nativeDir: string;
|
||||
helperPath: string;
|
||||
manifestPath: string;
|
||||
manifest: Record<string, unknown>;
|
||||
runtimeContents: Record<string, Buffer>;
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy source runtime policy', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'pinned source identity',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.mpv.sourceSha256 = '0'.repeat(64);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'pinned source URL',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.freetype.sourceUrl =
|
||||
'https://example.invalid/freetype.tar.xz';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'pinned git tag',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.libplacebo.sourceTag = 'main';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'pinned hwdata build input',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.hwdata.buildInput = {
|
||||
consumer: 'libdisplay-info',
|
||||
relativePath: '../pnp.ids',
|
||||
purpose:
|
||||
'PNP vendor lookup table compiled into libdisplay-info.',
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'git submodule record',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.libplacebo.sourceSubmodules = [
|
||||
`${'a'.repeat(40)} ../outside`,
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'duplicate git submodule records',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const record = `${'a'.repeat(40)} 3rdparty/example`;
|
||||
packages.libplacebo.sourceSubmodules = [record, record];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'unpinned git submodule commit',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.libplacebo.sourceSubmodules = [
|
||||
`${'f'.repeat(40)} 3rdparty/Vulkan-Headers`,
|
||||
`${'e'.repeat(40)} 3rdparty/fast_float`,
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'portable ABI baseline',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const runtimeAbi = sourceRuntime.runtimeAbi as {
|
||||
baseline: Record<string, unknown>;
|
||||
};
|
||||
runtimeAbi.baseline.glibcMaximum = '9.99';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'source-distribution obligation',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
sourceRuntime.sourceDistribution = 'Sources available.';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'FFmpeg LGPL flags',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const ffmpeg = sourceRuntime.ffmpeg as {
|
||||
configureFlags: string[];
|
||||
};
|
||||
ffmpeg.configureFlags = ['--disable-nonfree', '--enable-gpl'];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'mpv LGPL flags',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const mpv = sourceRuntime.mpv as {
|
||||
mesonFlags: string[];
|
||||
};
|
||||
mpv.mesonFlags = ['-Dgpl=true', '-Dlibmpv=true'];
|
||||
},
|
||||
},
|
||||
])('rejects an invalid $label', ({ mutate }) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
mutate(manifest.sourceRuntime as Record<string, unknown>);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import path from 'path';
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
GIT_COMMIT_PATTERN,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES,
|
||||
PORTABLE_ABI_BASELINE,
|
||||
SUBMODULE_RECORD_PATTERN,
|
||||
VERSION_PATTERN,
|
||||
} from './contracts';
|
||||
import type { RuntimeFile } from './types';
|
||||
import {
|
||||
hasExactFields,
|
||||
isObject,
|
||||
isSafeRuntimeName,
|
||||
} from './validation-primitives';
|
||||
|
||||
function compareDottedVersions(left: string, right: string): number {
|
||||
const leftParts = left.split('.').map(Number);
|
||||
const rightParts = right.split('.').map(Number);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
if (difference !== 0) {
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function validatesPinnedSourceIdentity(
|
||||
candidate: unknown,
|
||||
expected: (typeof PINNED_SOURCE_PACKAGE_IDENTITIES)[keyof typeof PINNED_SOURCE_PACKAGE_IDENTITIES]
|
||||
): boolean {
|
||||
if (
|
||||
!isObject(candidate) ||
|
||||
candidate.version !== expected.version ||
|
||||
candidate.sourceUrl !== expected.sourceUrl ||
|
||||
candidate.license !== expected.license
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
('sourceTag' in expected
|
||||
? candidate.sourceTag !== expected.sourceTag
|
||||
: candidate.sourceTag !== undefined) ||
|
||||
('buildInput' in expected
|
||||
? !isDeepStrictEqual(candidate.buildInput, expected.buildInput)
|
||||
: candidate.buildInput !== undefined)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ('sourceSha256' in expected) {
|
||||
return (
|
||||
candidate.sourceSha256 === expected.sourceSha256 &&
|
||||
candidate.sourceGitCommit === undefined &&
|
||||
candidate.sourceSubmodules === undefined
|
||||
);
|
||||
}
|
||||
return (
|
||||
'sourceSubmodules' in expected &&
|
||||
candidate.sourceGitCommit === expected.sourceGitCommit &&
|
||||
GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) &&
|
||||
candidate.sourceSha256 === undefined &&
|
||||
validatesGitSubmoduleRecords(candidate.sourceSubmodules) &&
|
||||
isDeepStrictEqual(candidate.sourceSubmodules, expected.sourceSubmodules)
|
||||
);
|
||||
}
|
||||
|
||||
function validatesGitSubmoduleRecords(value: unknown): boolean {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return value.every((record) => {
|
||||
if (typeof record !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const match = record.match(SUBMODULE_RECORD_PATTERN);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const submodulePath = match[1];
|
||||
return (
|
||||
!path.posix.isAbsolute(submodulePath) &&
|
||||
!submodulePath
|
||||
.split('/')
|
||||
.some((segment) => segment === '.' || segment === '..')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validateStringFlags(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(flag) =>
|
||||
typeof flag === 'string' &&
|
||||
flag.length > 0 &&
|
||||
flag.trim() === flag
|
||||
) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
|
||||
function validateRuntimeAbi(
|
||||
value: unknown,
|
||||
runtimeFiles: RuntimeFile[]
|
||||
): boolean {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!hasExactFields(value, ['baseline', 'files']) ||
|
||||
!isDeepStrictEqual(value.baseline, PORTABLE_ABI_BASELINE) ||
|
||||
!Array.isArray(value.files)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const abiFileNames: string[] = [];
|
||||
for (const record of value.files) {
|
||||
if (
|
||||
!isObject(record) ||
|
||||
!hasExactFields(record, [
|
||||
'name',
|
||||
'requiredGlibc',
|
||||
'requiredGlibcxx',
|
||||
]) ||
|
||||
!isSafeRuntimeName(record.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const [field, maximum] of [
|
||||
['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum],
|
||||
['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum],
|
||||
] as const) {
|
||||
const version = record[field];
|
||||
if (
|
||||
version !== null &&
|
||||
(typeof version !== 'string' ||
|
||||
!VERSION_PATTERN.test(version) ||
|
||||
compareDottedVersions(version, maximum) > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
abiFileNames.push(record.name);
|
||||
}
|
||||
|
||||
return isDeepStrictEqual(
|
||||
abiFileNames,
|
||||
runtimeFiles.map(({ name }) => name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source builder and package verifier own exhaustive build-host, recipe,
|
||||
* tool-version, URL and external-configuration validation. Startup repeats
|
||||
* only the immutable policy boundary needed before sandbox relaxation:
|
||||
* pinned source identities/licenses, LGPL flags, portable ABI, display-data
|
||||
* distribution, and the exact runtime closure mirrored by the package.
|
||||
*/
|
||||
export function validateSourceRuntimePolicy(
|
||||
value: unknown,
|
||||
runtimeFiles: RuntimeFile[],
|
||||
runtimeDependencyClosure: unknown,
|
||||
externalSystemLibraries: unknown
|
||||
): boolean {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
value.origin !== 'vendored-lgpl-source-build' ||
|
||||
value.platform !== 'linux' ||
|
||||
value.arch !== 'x64' ||
|
||||
!isObject(value.packages) ||
|
||||
!isObject(value.ffmpeg) ||
|
||||
!isObject(value.mpv) ||
|
||||
!isDeepStrictEqual(
|
||||
Object.keys(value.packages).sort(),
|
||||
Object.keys(PINNED_SOURCE_PACKAGE_IDENTITIES).sort()
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [packageName, expectedIdentity] of Object.entries(
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES
|
||||
)) {
|
||||
if (
|
||||
!validatesPinnedSourceIdentity(
|
||||
value.packages[packageName],
|
||||
expectedIdentity
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!validatesPinnedSourceIdentity(
|
||||
value.ffmpeg,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES.ffmpeg
|
||||
) ||
|
||||
!validateStringFlags(value.ffmpeg.configureFlags) ||
|
||||
!value.ffmpeg.configureFlags.includes('--disable-gpl') ||
|
||||
!value.ffmpeg.configureFlags.includes('--disable-nonfree') ||
|
||||
value.ffmpeg.configureFlags.includes('--enable-gpl') ||
|
||||
value.ffmpeg.configureFlags.includes('--enable-nonfree') ||
|
||||
!validatesPinnedSourceIdentity(
|
||||
value.mpv,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES.mpv
|
||||
) ||
|
||||
!validateStringFlags(value.mpv.mesonFlags) ||
|
||||
!value.mpv.mesonFlags.includes('-Dgpl=false') ||
|
||||
!value.mpv.mesonFlags.includes('-Dlibmpv=true') ||
|
||||
value.mpv.mesonFlags.includes('-Dgpl=true')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.sourceDistribution !== 'string' ||
|
||||
!/\bhwdata\b/i.test(value.sourceDistribution) ||
|
||||
!/\bpnp\.ids\b/i.test(value.sourceDistribution) ||
|
||||
!/\blibdisplay-info\b/i.test(value.sourceDistribution) ||
|
||||
value.runtimeTotalBytes !==
|
||||
runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
) ||
|
||||
!isDeepStrictEqual(value.runtimeFiles, runtimeFiles) ||
|
||||
!isDeepStrictEqual(
|
||||
value.runtimeDependencyClosure,
|
||||
runtimeDependencyClosure
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
value.externalSystemLibraries,
|
||||
externalSystemLibraries
|
||||
) ||
|
||||
!validateRuntimeAbi(value.runtimeAbi, runtimeFiles)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import path from 'path';
|
||||
|
||||
const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const;
|
||||
|
||||
function isPathInside(
|
||||
parentPath: string,
|
||||
candidatePath: string,
|
||||
allowEqual: boolean
|
||||
): boolean {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
if (relativePath === '') {
|
||||
return allowEqual;
|
||||
}
|
||||
return (
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTrustedSnapRoot(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
nativeDir: string
|
||||
): string | null {
|
||||
const declaredSnapRoot = environment.SNAP;
|
||||
if (
|
||||
!declaredSnapRoot ||
|
||||
!path.isAbsolute(declaredSnapRoot) ||
|
||||
!path.isAbsolute(nativeDir)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSnapRoot = path.resolve(declaredSnapRoot);
|
||||
const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some(
|
||||
(mountRoot) => {
|
||||
const relativePath = path.relative(mountRoot, normalizedSnapRoot);
|
||||
return (
|
||||
isPathInside(mountRoot, normalizedSnapRoot, false) &&
|
||||
relativePath.split(path.sep).filter(Boolean).length >= 2
|
||||
);
|
||||
}
|
||||
);
|
||||
if (
|
||||
!resemblesReadOnlySnapMount ||
|
||||
!isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalizedSnapRoot;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { spawnSync as nodeSpawnSync } from 'child_process';
|
||||
import type * as nodeFileSystem from 'fs';
|
||||
|
||||
export const EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS = {
|
||||
MPV_CREATE_FAILED: 'mpv-create-failed',
|
||||
MPV_INITIALIZE_FAILED: 'mpv-initialize-failed',
|
||||
GL_CONTEXT_CREATE_FAILED: 'gl-context-create-failed',
|
||||
GL_CONTEXT_BIND_FAILED: 'gl-context-bind-failed',
|
||||
MPV_RENDER_CONTEXT_FAILED: 'mpv-render-context-failed',
|
||||
SHARED_MEMORY_CREATE_FAILED: 'shared-memory-create-failed',
|
||||
SHARED_MEMORY_INITIALIZE_FAILED: 'shared-memory-initialize-failed',
|
||||
} as const;
|
||||
|
||||
export type EmbeddedMpvHelperRuntimeProbeFailureReason =
|
||||
(typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)[keyof typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS];
|
||||
|
||||
export type EmbeddedMpvFrameCopyRuntimeFailureReason =
|
||||
| 'unsupported-platform'
|
||||
| 'unsupported-architecture'
|
||||
| 'runtime-manifest-missing'
|
||||
| 'runtime-manifest-invalid'
|
||||
| 'runtime-artifact-missing'
|
||||
| 'runtime-artifact-invalid'
|
||||
| 'runtime-library-directory-invalid'
|
||||
| 'runtime-library-missing'
|
||||
| 'runtime-library-undeclared'
|
||||
| 'runtime-library-invalid'
|
||||
| 'runtime-library-size-mismatch'
|
||||
| 'runtime-library-hash-mismatch'
|
||||
| 'snap-graphics-provider-unavailable'
|
||||
| 'helper-probe-timeout'
|
||||
| 'helper-probe-spawn-error'
|
||||
| 'helper-probe-signaled'
|
||||
| 'helper-probe-failed'
|
||||
| 'helper-probe-invalid-output'
|
||||
| 'helper-probe-protocol-mismatch'
|
||||
| 'helper-probe-unusable'
|
||||
| 'runtime-probe-internal-error';
|
||||
|
||||
export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development';
|
||||
export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled';
|
||||
|
||||
export type RuntimeProfile = 'system' | 'portable' | 'flatpak';
|
||||
export type RuntimeProbeProfile = RuntimeProfile | 'development';
|
||||
|
||||
export type EmbeddedMpvFrameCopyRuntimeResult =
|
||||
| {
|
||||
usable: true;
|
||||
profile: RuntimeProbeProfile;
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
libmpv: string;
|
||||
renderApi: 'egl';
|
||||
}
|
||||
| {
|
||||
usable: false;
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
|
||||
helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason;
|
||||
helperDetail?: string;
|
||||
};
|
||||
|
||||
export interface EmbeddedMpvFrameCopyRuntimeFileSystem {
|
||||
accessSync(filePath: string, mode: number): void;
|
||||
lstatSync(filePath: string): nodeFileSystem.Stats;
|
||||
readFileSync(filePath: string): Buffer;
|
||||
readdirSync(filePath: string): string[];
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvFrameCopyRuntimeDependencies {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem;
|
||||
spawnSync: typeof nodeSpawnSync;
|
||||
writeStderr(output: string): void;
|
||||
}
|
||||
|
||||
export interface RuntimeFile {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ValidManifest {
|
||||
profile: RuntimeProbeProfile;
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
runtimeFiles: RuntimeFile[];
|
||||
}
|
||||
|
||||
export interface ValidatedPackage {
|
||||
manifest: ValidManifest;
|
||||
helperPath: string;
|
||||
nativeDir: string;
|
||||
}
|
||||
|
||||
export interface ValidationSuccess<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export interface ValidationFailure {
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
|
||||
}
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { createHash } from 'crypto';
|
||||
import type * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
SAFE_RUNTIME_NAME_PATTERN,
|
||||
SHA256_PATTERN,
|
||||
SHARED_LIBRARY_PATTERN,
|
||||
} from './contracts';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyRuntimeFailureReason,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
RuntimeFile,
|
||||
ValidationFailure,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
|
||||
export function failure(
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
return { usable: false, reason };
|
||||
}
|
||||
|
||||
export function validationFailure(
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
|
||||
): ValidationFailure {
|
||||
return { reason };
|
||||
}
|
||||
|
||||
export function isValidationFailure<T>(
|
||||
result: ValidationResult<T>
|
||||
): result is ValidationFailure {
|
||||
return 'reason' in result;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function hasExactFields(
|
||||
value: Record<string, unknown>,
|
||||
fields: readonly string[]
|
||||
): boolean {
|
||||
return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort());
|
||||
}
|
||||
|
||||
export function isSafeRuntimeName(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
path.basename(value) === value &&
|
||||
!value.includes('/') &&
|
||||
!value.includes('\\') &&
|
||||
SAFE_RUNTIME_NAME_PATTERN.test(value) &&
|
||||
SHARED_LIBRARY_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingFileError(error: unknown): boolean {
|
||||
return (
|
||||
isObject(error) &&
|
||||
typeof error.code === 'string' &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
);
|
||||
}
|
||||
|
||||
export function fileIdentity(
|
||||
filePath: string,
|
||||
stat: nodeFileSystem.Stats,
|
||||
contents: Buffer
|
||||
): string {
|
||||
return [
|
||||
path.resolve(filePath),
|
||||
stat.dev,
|
||||
stat.ino,
|
||||
stat.mode,
|
||||
stat.size,
|
||||
stat.mtimeMs,
|
||||
stat.ctimeMs,
|
||||
createHash('sha256').update(contents).digest('hex'),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function readManifest(
|
||||
manifestPath: string,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): ValidationResult<Record<string, unknown>> {
|
||||
let contents: Buffer;
|
||||
try {
|
||||
contents = fileSystem.readFileSync(manifestPath);
|
||||
} catch {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(contents.toString('utf8'));
|
||||
return isObject(parsed)
|
||||
? { value: parsed }
|
||||
: validationFailure('runtime-manifest-invalid');
|
||||
} catch {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateTargets(
|
||||
targets: unknown,
|
||||
allowedTargets: ReadonlySet<string>
|
||||
): boolean {
|
||||
const expectedTargets = [...allowedTargets].sort();
|
||||
if (
|
||||
!Array.isArray(targets) ||
|
||||
targets.length !== expectedTargets.length ||
|
||||
targets.some(
|
||||
(target) =>
|
||||
typeof target !== 'string' ||
|
||||
target.trim() !== target ||
|
||||
target.toLowerCase() !== target ||
|
||||
!allowedTargets.has(target)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isDeepStrictEqual(targets, expectedTargets);
|
||||
}
|
||||
|
||||
export function validateRuntimeFiles(value: unknown): RuntimeFile[] | null {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeFiles: RuntimeFile[] = [];
|
||||
const names = new Set<string>();
|
||||
for (const candidate of value) {
|
||||
if (
|
||||
!isObject(candidate) ||
|
||||
!hasExactFields(candidate, ['name', 'sha256', 'size']) ||
|
||||
!isSafeRuntimeName(candidate.name) ||
|
||||
!Number.isSafeInteger(candidate.size) ||
|
||||
(candidate.size as number) <= 0 ||
|
||||
typeof candidate.sha256 !== 'string' ||
|
||||
!SHA256_PATTERN.test(candidate.sha256) ||
|
||||
names.has(candidate.name)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
names.add(candidate.name);
|
||||
runtimeFiles.push({
|
||||
name: candidate.name,
|
||||
size: candidate.size as number,
|
||||
sha256: candidate.sha256,
|
||||
});
|
||||
}
|
||||
return isDeepStrictEqual(
|
||||
runtimeFiles.map(({ name }) => name).sort(),
|
||||
runtimeFiles.map(({ name }) => name)
|
||||
)
|
||||
? runtimeFiles
|
||||
: null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { Stats } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const spawnMock = jest.fn();
|
||||
@@ -7,6 +8,55 @@ jest.mock('child_process', () => ({
|
||||
}));
|
||||
|
||||
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
|
||||
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
|
||||
|
||||
const HOSTILE_LOADER_ENVIRONMENT = {
|
||||
BASH_ENV: '/tmp/hostile-bash-env',
|
||||
ENV: '/tmp/hostile-shell-env',
|
||||
BASHOPTS: 'extdebug',
|
||||
SHELLOPTS: 'xtrace',
|
||||
PS4: '$(/tmp/hostile-trace-hook)',
|
||||
BASH_XTRACEFD: '9',
|
||||
CDPATH: '/tmp/hostile-cdpath',
|
||||
'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }',
|
||||
LD_AUDIT: '/tmp/audit.so',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
LD_ORIGIN_PATH: '/tmp/hostile-origin',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json',
|
||||
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
|
||||
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
|
||||
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
|
||||
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
|
||||
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
|
||||
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
|
||||
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
|
||||
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
|
||||
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
|
||||
VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
|
||||
} as const;
|
||||
|
||||
const GRAPHICS_SELECTOR_ENVIRONMENT = {
|
||||
LIBGL_ALWAYS_SOFTWARE: '1',
|
||||
GALLIUM_DRIVER: 'llvmpipe',
|
||||
} as const;
|
||||
|
||||
function fakeStat(
|
||||
kind: 'directory' | 'file'
|
||||
): Pick<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
|
||||
return {
|
||||
isDirectory: () => kind === 'directory',
|
||||
isFile: () => kind === 'file',
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeHelperProcess extends EventEmitter {
|
||||
exitCode: number | null = null;
|
||||
@@ -36,14 +86,34 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
let frameSourceChanges: Array<{ sessionId: string; shmName: string }>;
|
||||
let adapter: EmbeddedMpvFrameCopyAdapter;
|
||||
|
||||
const createAdapter = (helperPath: string | null = '/native/helper') => {
|
||||
const createAdapter = (
|
||||
helperPath: string | null = '/native/helper',
|
||||
{
|
||||
runtimeMode = 'system',
|
||||
environment,
|
||||
helperLaunchFileSystem,
|
||||
}: {
|
||||
runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
helperLaunchFileSystem?: {
|
||||
lstatSync(filePath: string): Stats;
|
||||
accessSync(filePath: string, mode: number): void;
|
||||
};
|
||||
} = {}
|
||||
) => {
|
||||
frameSourceChanges = [];
|
||||
return new EmbeddedMpvFrameCopyAdapter({
|
||||
resolveHelperPath: () => helperPath,
|
||||
resolveRuntimeMode: () => runtimeMode,
|
||||
environment,
|
||||
helperLaunchFileSystem,
|
||||
getScaleFactor: () => 2,
|
||||
onFrameSourceChanged: (sessionId, source) =>
|
||||
frameSourceChanges.push({ sessionId, shmName: source.shmName }),
|
||||
});
|
||||
frameSourceChanges.push({
|
||||
sessionId,
|
||||
shmName: source.shmName,
|
||||
}),
|
||||
} as ConstructorParameters<typeof EmbeddedMpvFrameCopyAdapter>[0]);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -83,6 +153,269 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Linux loader environment', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||
});
|
||||
|
||||
it('uses a sanitized system environment for the real helper session', () => {
|
||||
adapter = createAdapter('/opt/iptvnator/native/helper', {
|
||||
runtimeMode: 'system',
|
||||
environment: {
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
...HOSTILE_LOADER_ENVIRONMENT,
|
||||
...GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
|
||||
createSession();
|
||||
|
||||
expect(spawnMock.mock.calls[0][2]).toEqual({
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
...GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => {
|
||||
const snapRoot = '/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
adapter = createAdapter(path.join(nativeDir, 'helper'), {
|
||||
runtimeMode: 'bundled',
|
||||
helperLaunchFileSystem: {
|
||||
lstatSync: (candidatePath) =>
|
||||
fakeStat(
|
||||
candidatePath.endsWith('/graphics')
|
||||
? 'directory'
|
||||
: 'file'
|
||||
) as Stats,
|
||||
accessSync: () => undefined,
|
||||
},
|
||||
environment: {
|
||||
PATH: '/snap/bin:/usr/bin',
|
||||
SNAP: snapRoot,
|
||||
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
|
||||
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
|
||||
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
|
||||
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
|
||||
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
|
||||
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
|
||||
'/tmp/hostile-egl-platform',
|
||||
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
|
||||
VK_LAYER_PATH: '/tmp/hostile-vulkan',
|
||||
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
|
||||
XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs',
|
||||
XDG_DATA_HOME: '/tmp/hostile-xdg-data-home',
|
||||
XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs',
|
||||
...HOSTILE_LOADER_ENVIRONMENT,
|
||||
...GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
|
||||
createSession();
|
||||
|
||||
expect(spawnMock.mock.calls[0][0]).toBe(
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'bin',
|
||||
'graphics-core22-provider-wrapper'
|
||||
)
|
||||
);
|
||||
expect(spawnMock.mock.calls[0][1][0]).toBe(
|
||||
path.join(nativeDir, 'helper')
|
||||
);
|
||||
expect(spawnMock.mock.calls[0][2]).toEqual({
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
SNAP: snapRoot,
|
||||
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl',
|
||||
SNAP_ARCH: 'amd64',
|
||||
SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu',
|
||||
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
|
||||
...GRAPHICS_SELECTOR_ENVIRONMENT,
|
||||
GBM_BACKENDS_PATH: [
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'gbm'
|
||||
),
|
||||
'/var/lib/snapd/lib/gl/gbm',
|
||||
].join(':'),
|
||||
LIBGL_DRIVERS_PATH: path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'dri'
|
||||
),
|
||||
LIBVA_DRIVERS_PATH: path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'dri'
|
||||
),
|
||||
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'egl',
|
||||
'egl_external_platform.d'
|
||||
),
|
||||
__EGL_VENDOR_LIBRARY_DIRS: [
|
||||
'/var/lib/snapd/lib/glvnd/egl_vendor.d',
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'glvnd',
|
||||
'egl_vendor.d'
|
||||
),
|
||||
].join(':'),
|
||||
VK_LAYER_PATH: [
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'vulkan',
|
||||
'implicit_layer.d'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'share',
|
||||
'vulkan',
|
||||
'explicit_layer.d'
|
||||
),
|
||||
].join(':'),
|
||||
XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'),
|
||||
XDG_CONFIG_DIRS: [
|
||||
path.join(snapRoot, 'etc', 'xdg'),
|
||||
'/etc/xdg',
|
||||
].join(':'),
|
||||
XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'),
|
||||
XDG_DATA_DIRS: [
|
||||
path.join(snapRoot, 'graphics', 'usr', 'share'),
|
||||
path.join(snapRoot, 'gnome-platform', 'usr', 'share'),
|
||||
path.join(snapRoot, 'usr', 'share'),
|
||||
'/usr/share',
|
||||
].join(':'),
|
||||
LD_LIBRARY_PATH: [
|
||||
path.join(nativeDir, 'lib'),
|
||||
'/var/lib/snapd/lib/gl',
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'graphics',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'vdpau'
|
||||
),
|
||||
'/usr/lib/x86_64-linux-gnu',
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'mesa'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'mesa-egl'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'dri'
|
||||
),
|
||||
path.join(
|
||||
snapRoot,
|
||||
'gnome-platform',
|
||||
'usr',
|
||||
'lib',
|
||||
'x86_64-linux-gnu',
|
||||
'pulseaudio'
|
||||
),
|
||||
path.join(snapRoot, 'lib'),
|
||||
path.join(snapRoot, 'usr', 'lib'),
|
||||
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
|
||||
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
|
||||
].join(':'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a Linux session without a validated runtime mode', () => {
|
||||
adapter = createAdapter('/native/helper', { runtimeMode: null });
|
||||
|
||||
expect(() => createSession()).toThrow(
|
||||
'validated Linux frame-copy runtime'
|
||||
);
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('caches helper snapshot events for getSessionSnapshot', () => {
|
||||
const sessionId = createSession();
|
||||
child.emitStdout({
|
||||
@@ -149,7 +482,12 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
'size\twidth=1600\theight=900\n'
|
||||
);
|
||||
const writesBefore = child.stdin.written.length;
|
||||
adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 });
|
||||
adapter.setBounds(sessionId, {
|
||||
x: -10000,
|
||||
y: -10000,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
expect(child.stdin.written.length).toBe(writesBefore);
|
||||
});
|
||||
|
||||
@@ -192,7 +530,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
['darwin', 'arm64', true],
|
||||
['darwin', 'x64', false],
|
||||
['linux', 'x64', true],
|
||||
['linux', 'arm64', true],
|
||||
['linux', 'arm64', false],
|
||||
['win32', 'x64', true],
|
||||
['freebsd', 'x64', false],
|
||||
])(
|
||||
|
||||
Loaded 100 of 425 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user