mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/app-build-commit-info
This commit is contained in:
commit
a770ce89f0
41 files changed
+2110
-180
No files matched your search
@@ -16,6 +16,13 @@ jobs:
|
||||
name: Build pinned Linux Embedded MPV runtime
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 120
|
||||
# Concurrency lives on the build jobs, not the workflow: cancelling a
|
||||
# whole run could interrupt action-gh-release mid-update and leave the
|
||||
# rolling draft with missing assets. Build slots cancel superseded PR
|
||||
# work; the release job only serializes and is never cancelled.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-linux-runtime-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -304,6 +311,9 @@ jobs:
|
||||
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1208,6 +1218,9 @@ jobs:
|
||||
needs: linux-embedded-mpv-runtime
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1243,6 +1256,9 @@ jobs:
|
||||
- build-linux
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-release-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -1375,13 +1391,117 @@ jobs:
|
||||
id: package-version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
# Test drafts (PR/master) get a self-describing title plus a context
|
||||
# header linking the PR, real head commit, and workflow run. A stable
|
||||
# tag per PR (test-pr-<n>) / branch (test-master) makes the action
|
||||
# update one rolling draft in place instead of piling up a new draft
|
||||
# for every push. PR builds must not use github.sha here: that is the
|
||||
# ephemeral merge-commit SHA, which resolves to nothing in the repo.
|
||||
- name: Compose release metadata
|
||||
id: release-meta
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.package-version.outputs.version }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IS_TAG_BUILD: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SHORT_SHA="${HEAD_SHA:0:7}"
|
||||
SAFE_BRANCH="${SOURCE_BRANCH//\//-}"
|
||||
|
||||
if [ "${IS_TAG_BUILD}" = "true" ]; then
|
||||
NAME="Release v${VERSION}"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
BODY=""
|
||||
elif [ "${EVENT_NAME}" = "pull_request" ]; then
|
||||
NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]"
|
||||
TAG="test-pr-${PR_NUMBER}"
|
||||
BODY="$(printf '🧪 Test build for PR [#%s](%s) — %s\n\nCommit [`%s`](%s/commit/%s) · branch `%s` · [workflow run](%s)' \
|
||||
"${PR_NUMBER}" "${PR_URL}" "${PR_TITLE}" \
|
||||
"${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${SOURCE_BRANCH}" "${RUN_URL}")"
|
||||
else
|
||||
NAME="v${VERSION} — ${SAFE_BRANCH} @ ${SHORT_SHA} [test]"
|
||||
TAG="test-${SAFE_BRANCH}"
|
||||
BODY="$(printf '🧪 Test build from `%s` — commit [`%s`](%s/commit/%s) · [workflow run](%s)' \
|
||||
"${SOURCE_BRANCH}" "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}")"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "name=${NAME}"
|
||||
echo "tag=${TAG}"
|
||||
echo "commitish=${HEAD_SHA}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -n "${BODY}" ]; then
|
||||
{
|
||||
echo "body<<RELEASE_BODY_EOF"
|
||||
echo "${BODY}"
|
||||
echo "RELEASE_BODY_EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "body=" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
# A PR can be closed while this workflow is still running; the
|
||||
# cleanup workflow deletes the PR draft on close. Re-check the live
|
||||
# PR state right before touching the draft so a late-finishing run
|
||||
# cannot recreate a draft for a closed PR.
|
||||
- name: Check PR is still open
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr-state
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}" --jq '.state')" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# The rolling draft keeps assets across runs and the release action
|
||||
# only replaces same-name files. If the app version changes between
|
||||
# pushes, old-version installers would linger beside the new set,
|
||||
# so drop every existing asset first — the action re-uploads the
|
||||
# full current set right after. Only drafts are pruned; published
|
||||
# releases are never touched.
|
||||
- name: Prune stale draft assets
|
||||
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate |
|
||||
jq -s --arg tag "${RELEASE_TAG}" \
|
||||
'add | [.[] | select(.draft and .tag_name == $tag)][0].id // empty')"
|
||||
|
||||
if [ -z "${release_id}" ]; then
|
||||
echo "No existing draft for ${RELEASE_TAG}; nothing to prune."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" --paginate --jq '.[].id' |
|
||||
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/{}"
|
||||
|
||||
echo "Pruned assets from draft ${release_id} (${RELEASE_TAG})."
|
||||
|
||||
- name: Create Draft Release
|
||||
id: draft-release
|
||||
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ github.event_name == 'pull_request' }}
|
||||
name: Release v${{ steps.package-version.outputs.version }}
|
||||
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('test-{0}', github.sha) }}
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
name: ${{ steps.release-meta.outputs.name }}
|
||||
tag_name: ${{ steps.release-meta.outputs.tag }}
|
||||
target_commitish: ${{ steps.release-meta.outputs.commitish }}
|
||||
body: ${{ steps.release-meta.outputs.body }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/macos-x64-artifacts/*-x64.dmg
|
||||
@@ -1408,3 +1528,58 @@ jobs:
|
||||
artifacts/windows-artifacts/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Rare action-gh-release path: when the release listing transiently
|
||||
# misses the rolling draft, the action creates a duplicate, deletes
|
||||
# it in favor of the canonical (oldest) draft, and uploads assets
|
||||
# there WITHOUT refreshing that draft's metadata. Rebuild the full
|
||||
# metadata (title, commitish, and body = context header + notes
|
||||
# from the same generate-notes API the action uses) on the release
|
||||
# id the action actually used, so the draft ends up correct no
|
||||
# matter which internal path ran. If notes generation fails, the
|
||||
# body is left as the action set it and only title/commitish are
|
||||
# re-asserted.
|
||||
- name: Ensure draft metadata is current
|
||||
if: steps.draft-release.outputs.id != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
|
||||
RELEASE_NAME: ${{ steps.release-meta.outputs.name }}
|
||||
RELEASE_COMMITISH: ${{ steps.release-meta.outputs.commitish }}
|
||||
RELEASE_BODY: ${{ steps.release-meta.outputs.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
GENERATED_NOTES="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
|
||||
-f tag_name="${RELEASE_TAG}" \
|
||||
-f target_commitish="${RELEASE_COMMITISH}" \
|
||||
--jq '.body' || true)"
|
||||
|
||||
# tag_name MUST be included in every PATCH: updating a draft
|
||||
# without it makes GitHub drop the pending tag (the draft
|
||||
# becomes "untagged-<hash>"), which breaks the rolling-draft
|
||||
# lookup on the next run.
|
||||
if [ -z "${GENERATED_NOTES}" ]; then
|
||||
jq -n \
|
||||
--arg tag "${RELEASE_TAG}" \
|
||||
--arg name "${RELEASE_NAME}" \
|
||||
--arg commitish "${RELEASE_COMMITISH}" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: $commitish}' |
|
||||
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${RELEASE_BODY}" ]; then
|
||||
FULL_BODY="$(printf '%s\n\n%s' "${RELEASE_BODY}" "${GENERATED_NOTES}")"
|
||||
else
|
||||
FULL_BODY="${GENERATED_NOTES}"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg tag "${RELEASE_TAG}" \
|
||||
--arg name "${RELEASE_NAME}" \
|
||||
--arg commitish "${RELEASE_COMMITISH}" \
|
||||
--arg body "${FULL_BODY}" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' |
|
||||
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
|
||||
@@ -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/{}"
|
||||
@@ -73,6 +73,10 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
- `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
|
||||
|
||||
- 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.
|
||||
|
||||
- GPU/compositor debugging:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -130,6 +130,10 @@ Useful narrower flags:
|
||||
- `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
|
||||
|
||||
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.
|
||||
|
||||
For GPU/compositor debugging:
|
||||
|
||||
```bash
|
||||
@@ -238,6 +242,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
|
||||
@@ -616,7 +621,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
|
||||
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
|
||||
- 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 (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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -41,20 +41,29 @@ const appMock = {
|
||||
commandLine: commandLineMock,
|
||||
};
|
||||
|
||||
const screenGetDisplayMatchingMock = jest.fn();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: appMock,
|
||||
powerSaveBlocker: powerSaveBlockerMock,
|
||||
screen: { getDisplayMatching: screenGetDisplayMatchingMock },
|
||||
}));
|
||||
|
||||
const mainWindowSendMock = jest.fn();
|
||||
const mainWindowWebContentsOnMock = jest.fn();
|
||||
const mainWindowGetZoomFactorMock = jest.fn<number, []>();
|
||||
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
|
||||
Buffer.alloc(8)
|
||||
);
|
||||
const mainWindowMock = {
|
||||
isDestroyed: () => false,
|
||||
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
|
||||
webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock },
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
webContents: {
|
||||
send: mainWindowSendMock,
|
||||
on: mainWindowWebContentsOnMock,
|
||||
getZoomFactor: mainWindowGetZoomFactorMock,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../app', () => ({
|
||||
@@ -155,6 +164,10 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
|
||||
mainWindowSendMock.mockReset();
|
||||
mainWindowWebContentsOnMock.mockReset();
|
||||
mainWindowGetZoomFactorMock.mockReset();
|
||||
mainWindowGetZoomFactorMock.mockReturnValue(1);
|
||||
screenGetDisplayMatchingMock.mockReset();
|
||||
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1 });
|
||||
appMock.isPackaged = true;
|
||||
|
||||
tempDirs = [];
|
||||
@@ -440,6 +453,96 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('native view bounds scaling', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
||||
});
|
||||
|
||||
it('converts CSS bounds to physical pixels for the native engine on scaled displays', () => {
|
||||
// Regression for #1145: the win32/linux engines position their
|
||||
// child window in physical pixels, so renderer CSS bounds must
|
||||
// be multiplied by the display scale before reaching the addon.
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 });
|
||||
addon.createSession.mockReturnValueOnce('s-scaled');
|
||||
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
|
||||
|
||||
const cssBounds = { x: 100, y: 40, width: 640, height: 360 };
|
||||
service.createSession(cssBounds, '', 1);
|
||||
service.setBounds('s-scaled', cssBounds);
|
||||
|
||||
const physicalBounds = { x: 150, y: 60, width: 960, height: 540 };
|
||||
expect(addon.createSession).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
physicalBounds,
|
||||
'',
|
||||
1
|
||||
);
|
||||
expect(addon.setBounds).toHaveBeenCalledWith(
|
||||
's-scaled',
|
||||
physicalBounds
|
||||
);
|
||||
});
|
||||
|
||||
it('applies page zoom but not the display scale on macOS', () => {
|
||||
// NSView frames are in points (device-independent pixels): only
|
||||
// the webContents zoom factor separates them from CSS pixels.
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 2 });
|
||||
mainWindowGetZoomFactorMock.mockReturnValue(1.25);
|
||||
addon.createSession.mockReturnValueOnce('s-zoom');
|
||||
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
|
||||
|
||||
service.createSession({ x: 0, y: 0, width: 100, height: 100 }, '', 1);
|
||||
|
||||
expect(addon.createSession).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
{ x: 0, y: 0, width: 125, height: 125 },
|
||||
'',
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('passes frame-copy bounds through unscaled', () => {
|
||||
// The frame-copy engine paints into a DOM canvas laid out in CSS
|
||||
// pixels; its adapter applies the display scale to the render
|
||||
// size itself, so a second scaling pass here would double it.
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
|
||||
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
|
||||
usable: true,
|
||||
});
|
||||
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 });
|
||||
const frameCopyAddon = createMockAddon();
|
||||
frameCopyAddon.createSession.mockReturnValueOnce('s-fc-bounds');
|
||||
frameCopyAddon.getSessionSnapshot.mockReturnValue(
|
||||
snapshot('loading')
|
||||
);
|
||||
(
|
||||
service as unknown as { frameCopyAdapter: MockAddon }
|
||||
).frameCopyAdapter = frameCopyAddon;
|
||||
|
||||
service.createSession(BOUNDS, '', 1);
|
||||
service.setBounds('s-fc-bounds', BOUNDS);
|
||||
|
||||
expect(frameCopyAddon.createSession).toHaveBeenCalledWith(
|
||||
Buffer.alloc(0),
|
||||
BOUNDS,
|
||||
'',
|
||||
1
|
||||
);
|
||||
expect(frameCopyAddon.setBounds).toHaveBeenCalledWith(
|
||||
's-fc-bounds',
|
||||
BOUNDS
|
||||
);
|
||||
|
||||
// Dispose while the frame-copy env is still set so teardown
|
||||
// dispatches to the adapter that owns the session.
|
||||
service.disposeSession('s-fc-bounds');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not acquire a blocker for a loading session', () => {
|
||||
startSession('s1', snapshot('loading'));
|
||||
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
EMBEDDED_MPV_SESSION_UPDATE,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
|
||||
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
|
||||
import {
|
||||
getFrameCopyRuntimeAvailability,
|
||||
@@ -201,6 +202,36 @@ export class EmbeddedMpvNativeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getMainWindowZoomFactor(): number {
|
||||
try {
|
||||
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
||||
return 1;
|
||||
}
|
||||
return App.mainWindow.webContents.getZoomFactor();
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer bounds arrive in CSS pixels; the native-view engines position
|
||||
* OS windows in physical pixels (win32/linux) or points (macOS), so at
|
||||
* page zoom or display scale ≠ 100% the raw values land the video toward
|
||||
* the window's top-left corner at a fraction of its size (#1145). The
|
||||
* frame-copy engine must bypass this: it paints into a DOM canvas laid
|
||||
* out in CSS pixels, and its adapter already applies the display scale
|
||||
* to the render size itself.
|
||||
*/
|
||||
private scaleBoundsForNativeView(
|
||||
bounds: EmbeddedMpvBounds
|
||||
): EmbeddedMpvBounds {
|
||||
return toNativeViewBounds(bounds, {
|
||||
platform: process.platform,
|
||||
zoomFactor: this.getMainWindowZoomFactor(),
|
||||
displayScaleFactor: this.getMainWindowScaleFactor(),
|
||||
});
|
||||
}
|
||||
|
||||
private detectCapabilities(): EmbeddedMpvCapabilities {
|
||||
if (this.isFrameCopyEngineActive()) {
|
||||
return {
|
||||
@@ -419,14 +450,15 @@ export class EmbeddedMpvNativeService {
|
||||
// embed into the window at all. Derive the skip from the dispatched
|
||||
// addon rather than re-evaluating the engine gate, so the two
|
||||
// decisions cannot disagree.
|
||||
const windowHandle =
|
||||
this.frameCopyAdapter && addon === this.frameCopyAdapter
|
||||
? Buffer.alloc(0)
|
||||
: this.getMainWindowHandle();
|
||||
const usesFrameCopyAddon =
|
||||
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
|
||||
const windowHandle = usesFrameCopyAddon
|
||||
? Buffer.alloc(0)
|
||||
: this.getMainWindowHandle();
|
||||
const startedAt = new Date().toISOString();
|
||||
const sessionId = addon.createSession(
|
||||
windowHandle,
|
||||
bounds,
|
||||
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
|
||||
title,
|
||||
initialVolume
|
||||
);
|
||||
@@ -478,7 +510,13 @@ export class EmbeddedMpvNativeService {
|
||||
|
||||
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().setBounds(sessionId, bounds);
|
||||
const addon = this.getAddon();
|
||||
const usesFrameCopyAddon =
|
||||
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
|
||||
addon.setBounds(
|
||||
sessionId,
|
||||
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds)
|
||||
);
|
||||
}
|
||||
|
||||
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('ElectronService', () => {
|
||||
const session = { id: 'session-1' };
|
||||
let electronBridge: {
|
||||
fetchPlaylistByUrl: jest.Mock;
|
||||
onPlayerError: jest.Mock;
|
||||
openInMpv: jest.Mock;
|
||||
openInVlc: jest.Mock;
|
||||
};
|
||||
@@ -24,9 +25,11 @@ describe('ElectronService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
electronBridge = {
|
||||
fetchPlaylistByUrl: jest.fn(),
|
||||
onPlayerError: jest.fn(),
|
||||
openInMpv: jest.fn().mockResolvedValue(session),
|
||||
openInVlc: jest.fn().mockResolvedValue(session),
|
||||
};
|
||||
@@ -99,6 +102,23 @@ describe('ElectronService', () => {
|
||||
expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts credentials from backend player errors before logging', () => {
|
||||
const secret = 'player-error-token-secret';
|
||||
const listener = electronBridge.onPlayerError.mock.calls[0][0];
|
||||
|
||||
listener({
|
||||
player: 'MPV',
|
||||
error: 'Playback failed',
|
||||
originalError: `Request failed: token=${secret}&channel=news`,
|
||||
});
|
||||
|
||||
const output = JSON.stringify(
|
||||
(console.error as jest.Mock).mock.calls
|
||||
);
|
||||
expect(output).not.toContain(secret);
|
||||
expect(output).toContain('channel=news');
|
||||
});
|
||||
|
||||
it('shows the trust-host action for Electron-wrapped security errors', async () => {
|
||||
const securityPayload = {
|
||||
code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DialogService } from '@iptvnator/ui/components';
|
||||
import { DataService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
createDevLogger,
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
|
||||
ERROR,
|
||||
normalizeHost,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppConfig } from '../../environments/environment';
|
||||
import {
|
||||
createLogger,
|
||||
createPortalDebugRequestContext,
|
||||
logPortalDebugEvent,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
@@ -54,7 +54,7 @@ export class ElectronService extends DataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly debugLog = createDevLogger('ElectronService');
|
||||
private readonly logger = createLogger('ElectronService');
|
||||
private readonly silentXtreamActions = new Set<string>([
|
||||
XtreamCodeActions.GetAccountInfo,
|
||||
XtreamCodeActions.GetLiveCategories,
|
||||
@@ -67,7 +67,6 @@ export class ElectronService extends DataService {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.debugLog('Electron service initialized...');
|
||||
this.setupPlayerErrorListener();
|
||||
this.setupPortalDebugListener();
|
||||
}
|
||||
@@ -81,7 +80,10 @@ export class ElectronService extends DataService {
|
||||
error: string;
|
||||
originalError: string;
|
||||
}) => {
|
||||
console.error(`${data.player} Error:`, data.originalError);
|
||||
this.logger.error(
|
||||
`${data.player} Error:`,
|
||||
data.originalError
|
||||
);
|
||||
this.snackBar.open(
|
||||
`${data.player} Error: ${data.error}`,
|
||||
'Close',
|
||||
@@ -182,7 +184,7 @@ export class ElectronService extends DataService {
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
console.error('MPV launch error:', error);
|
||||
this.logger.error('MPV launch error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -211,7 +213,7 @@ export class ElectronService extends DataService {
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
console.error('VLC launch error:', error);
|
||||
this.logger.error('VLC launch error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +239,7 @@ export class ElectronService extends DataService {
|
||||
return playlists as T;
|
||||
}
|
||||
|
||||
this.debugLog('Unknown IPC event type:', type);
|
||||
this.logger.debug('Unknown IPC event type:', type);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
@@ -265,7 +267,7 @@ export class ElectronService extends DataService {
|
||||
return response;
|
||||
} catch (err: unknown) {
|
||||
const errorInfo = this.getErrorDetails(err);
|
||||
console.error('Stalker request error:', err);
|
||||
this.logger.error('Stalker request error:', err);
|
||||
this.snackBar.open(
|
||||
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
||||
'Close',
|
||||
@@ -362,7 +364,7 @@ export class ElectronService extends DataService {
|
||||
data.title
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
this.logger.error(
|
||||
'Either url or filePath must be provided, but not both.'
|
||||
);
|
||||
return;
|
||||
@@ -387,7 +389,7 @@ export class ElectronService extends DataService {
|
||||
{ duration: 2000 }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error('Playlist refresh error:', error);
|
||||
this.logger.error('Playlist refresh error:', error);
|
||||
if (
|
||||
data.url &&
|
||||
this.handlePlaylistSecurityError(error, () => {
|
||||
@@ -590,12 +592,12 @@ export class ElectronService extends DataService {
|
||||
|
||||
// Log error to console
|
||||
if (isSilentAction) {
|
||||
this.debugLog(
|
||||
this.logger.debug(
|
||||
`Background Xtream action failed (${action ?? 'unknown'}):`,
|
||||
normalizedMessage
|
||||
);
|
||||
} else {
|
||||
console.error('Xtream request error:', normalizedMessage);
|
||||
this.logger.error('Xtream request error:', normalizedMessage);
|
||||
}
|
||||
|
||||
// Only show snackbar for user-triggered Xtream requests
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'rxjs';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
createDevLogger,
|
||||
ERROR,
|
||||
Playlist,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
createPortalDebugSuccessEvent,
|
||||
logPortalDebugEvent,
|
||||
logPortalDebugRequest,
|
||||
createLogger,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { getRuntimeBackendUrl } from './runtime-config';
|
||||
|
||||
@@ -71,7 +71,7 @@ export class PwaService extends DataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly swUpdate = inject(SwUpdate);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly debugLog = createDevLogger('PwaService');
|
||||
private readonly logger = createLogger('PwaService');
|
||||
private readonly providerTargetIds = new Map<string, Promise<string>>();
|
||||
private readonly silentXtreamActions = new Set<string>([
|
||||
XtreamCodeActions.GetAccountInfo,
|
||||
@@ -88,7 +88,6 @@ export class PwaService extends DataService {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.debugLog('PWA service initialized...');
|
||||
}
|
||||
|
||||
/** Uses service worker mechanism to check for available application updates */
|
||||
@@ -358,7 +357,7 @@ export class PwaService extends DataService {
|
||||
);
|
||||
|
||||
if (isSilentAction) {
|
||||
this.debugLog(
|
||||
this.logger.debug(
|
||||
`Background Xtream action failed (${action ?? 'unknown'}):`,
|
||||
normalizedMessage
|
||||
);
|
||||
@@ -398,7 +397,7 @@ export class PwaService extends DataService {
|
||||
|
||||
// Log error to console
|
||||
if (isSilentAction) {
|
||||
this.debugLog(
|
||||
this.logger.debug(
|
||||
`Background Xtream action failed (${action ?? 'unknown'}):`,
|
||||
normalizedMessage
|
||||
);
|
||||
@@ -409,7 +408,7 @@ export class PwaService extends DataService {
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Xtream request error:', normalizedMessage);
|
||||
this.logger.error('Xtream request error:', normalizedMessage);
|
||||
this.snackBar.open(
|
||||
`Xtream request failed: ${normalizedMessage}`,
|
||||
'Close',
|
||||
@@ -533,7 +532,7 @@ export class PwaService extends DataService {
|
||||
} catch (err: unknown) {
|
||||
const errorInfo = this.getErrorDetails(err);
|
||||
logPortalDebugEvent(createPortalDebugErrorEvent(context, err));
|
||||
console.error('Stalker request error:', err);
|
||||
this.logger.error('Stalker request error:', err);
|
||||
|
||||
this.snackBar.open(
|
||||
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
||||
|
||||
@@ -188,6 +188,26 @@ playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch
|
||||
is only for explicitly trusted providers with invalid or self-signed
|
||||
certificates when the host-scoped UI path is not available.
|
||||
|
||||
## Sensitive Diagnostic Logging
|
||||
|
||||
Settings, portal requests/responses, IPC trace payloads, and remote-request
|
||||
errors can contain provider credentials. Code at those boundaries must pass
|
||||
structured values through `redactSensitiveData` from
|
||||
`@iptvnator/shared/logging`, or through the portal `createLogger`/portal-debug
|
||||
helpers that apply it. Do not send a raw settings object, request params,
|
||||
response, or `Error` directly to `console.*`.
|
||||
|
||||
The redactor preserves non-sensitive diagnostic fields while replacing
|
||||
credential fields case-insensitively, including usernames, passwords, tokens,
|
||||
API keys, authorization/cookie headers, and MAC addresses. It also sanitizes
|
||||
URL query parameters, serialized JSON, nested query values, errors, arrays,
|
||||
and cyclic objects without mutating the original value. Depth, collection,
|
||||
object-key, and string limits keep opt-in debug traces bounded.
|
||||
|
||||
When adding a new logging boundary, extend the closest regression test with a
|
||||
synthetic secret and assert that the exact value is absent from captured log
|
||||
output. Never use a real provider credential to validate logging.
|
||||
|
||||
## Filesystem Capabilities
|
||||
|
||||
Renderer IPC payloads are not filesystem authorization.
|
||||
|
||||
@@ -150,9 +150,10 @@ The flow is:
|
||||
native-view, it starts `mpv --wid=<x11-window>` in a separate process with a
|
||||
private JSON IPC socket. Frame-copy instead uses the per-session helper
|
||||
described below.
|
||||
9. Resize, scroll, and fullscreen changes are measured in Angular and sent
|
||||
through bounds sync. Native-view uses them to align the platform host;
|
||||
frame-copy uses them to resize helper rendering and the canvas frame source.
|
||||
9. Resize, scroll, fullscreen, and devicePixelRatio changes are measured in
|
||||
Angular and sent through bounds sync. Native-view uses them to align the
|
||||
platform host; frame-copy uses them to resize helper rendering and the
|
||||
canvas frame source.
|
||||
10. Playback controls remain IPTVnator-owned Angular UI. Frame-copy uses the
|
||||
shared `app-player-controls` overlay through
|
||||
`EmbeddedMpvControlsAdapter`; native-view keeps its compositor-safe fixed
|
||||
@@ -595,6 +596,37 @@ there is no `HIDDEN_BOUNDS`, popover cutout, or reserved dock height. Dialogs
|
||||
and controls layer naturally over the canvas, while bounds sync still updates
|
||||
the helper's render size.
|
||||
|
||||
### Coordinate spaces (CSS → native units)
|
||||
|
||||
The renderer measures bounds in CSS pixels (`getBoundingClientRect()`), but
|
||||
the native-view engines position OS windows, not DOM nodes: the win32 child
|
||||
`HWND` (`SetWindowPos`) and the Linux child X11 window (`XMoveResizeWindow`)
|
||||
live in physical pixels, and the macOS `NSView` (`setFrame`) lives in points
|
||||
(device-independent pixels). CSS values match points only at 100% page zoom
|
||||
and match physical pixels only at 100% page zoom AND 100% display scale.
|
||||
`EmbeddedMpvNativeService` therefore converts every native-view bounds payload
|
||||
in the main process (`toNativeViewBounds` in `embedded-mpv-bounds.util.ts`):
|
||||
all platforms scale by the webContents zoom factor, win32/linux additionally
|
||||
by the scale factor of the display hosting the window. The renderer sends
|
||||
unrounded CSS edges (`measureBounds` does not round) and the conversion
|
||||
rounds exactly once, after scaling — edges first, width/height derived from
|
||||
them — so fractional CSS layouts and fractional scales cannot open 1px
|
||||
seams against the surrounding DOM UI. Skipping this conversion is issue #1145: on scaled
|
||||
displays (Windows 125%, Linux fractional scaling, HiDPI TVs) the video landed
|
||||
toward the window's top-left corner at `1/scale` of its size, in windowed and
|
||||
fullscreen mode alike.
|
||||
|
||||
Frame-copy bounds bypass the conversion: the canvas is laid out by the DOM in
|
||||
CSS pixels, and the frame-copy adapter already multiplies the render size by
|
||||
the display scale factor itself.
|
||||
|
||||
Because a monitor change can rescale this mapping without resizing the host
|
||||
element (moving the window to a display with a different scale keeps the DIP
|
||||
layout), the session controller also watches `devicePixelRatio` through a
|
||||
re-armed `matchMedia('(resolution: …dppx)')` query and re-syncs bounds when
|
||||
it changes; page zoom changes are covered by the same watch plus the ordinary
|
||||
resize-driven syncs.
|
||||
|
||||
### Controls ownership by engine
|
||||
|
||||
`EmbeddedMpvPlayerComponent` selects one control owner from
|
||||
|
||||
@@ -14,7 +14,9 @@ describe('portal debug logger', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
globalWithNgDevMode.ngDevMode = true;
|
||||
jest.spyOn(console, 'groupCollapsed').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'groupCollapsed').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
jest.spyOn(console, 'groupEnd').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
@@ -36,7 +38,9 @@ describe('portal debug logger', () => {
|
||||
});
|
||||
|
||||
logPortalDebugRequest(context);
|
||||
logPortalDebugEvent(createPortalDebugSuccessEvent(context, { ok: true }));
|
||||
logPortalDebugEvent(
|
||||
createPortalDebugSuccessEvent(context, { ok: true })
|
||||
);
|
||||
|
||||
expect(console.groupCollapsed).not.toHaveBeenCalled();
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
@@ -85,4 +89,43 @@ describe('portal debug logger', () => {
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('redacts portal request and response credentials before logging', () => {
|
||||
const secrets = {
|
||||
username: 'portal-user-secret',
|
||||
password: 'portal-password-secret',
|
||||
token: 'portal-token-secret',
|
||||
authorization: 'portal-authorization-secret',
|
||||
mac: 'portal-mac-secret',
|
||||
};
|
||||
const context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
operation: 'get_profile',
|
||||
transport: 'pwa-http',
|
||||
request: {
|
||||
params: secrets,
|
||||
url: `https://example.com/portal?token=${secrets.token}&action=get_profile`,
|
||||
diagnosticId: 'request-42',
|
||||
},
|
||||
});
|
||||
|
||||
logPortalDebugRequest(context);
|
||||
logPortalDebugEvent(
|
||||
createPortalDebugSuccessEvent(context, {
|
||||
user_info: secrets,
|
||||
status: 'ok',
|
||||
})
|
||||
);
|
||||
|
||||
const output = JSON.stringify([
|
||||
...(console.log as jest.Mock).mock.calls,
|
||||
...(console.error as jest.Mock).mock.calls,
|
||||
]);
|
||||
for (const secret of Object.values(secrets)) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('request-42');
|
||||
expect(output).toContain('get_profile');
|
||||
expect(output).toContain('status');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PortalDebugProvider,
|
||||
PortalDebugTransport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
|
||||
export interface Logger {
|
||||
debug: (...args: unknown[]) => void;
|
||||
@@ -26,19 +27,31 @@ export function createLogger(scope: string): Logger {
|
||||
return {
|
||||
debug: (...args: unknown[]) => {
|
||||
if (debugEnabled) {
|
||||
console.debug(prefix, ...args);
|
||||
console.debug(
|
||||
prefix,
|
||||
...args.map((arg) => redactSensitiveData(arg))
|
||||
);
|
||||
}
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
if (debugEnabled) {
|
||||
console.info(prefix, ...args);
|
||||
console.info(
|
||||
prefix,
|
||||
...args.map((arg) => redactSensitiveData(arg))
|
||||
);
|
||||
}
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
console.warn(prefix, ...args);
|
||||
console.warn(
|
||||
prefix,
|
||||
...args.map((arg) => redactSensitiveData(arg))
|
||||
);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
console.error(prefix, ...args);
|
||||
console.error(
|
||||
prefix,
|
||||
...args.map((arg) => redactSensitiveData(arg))
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -78,7 +91,7 @@ function logPortalDebugSection(
|
||||
method: 'log' | 'error' = 'log'
|
||||
): void {
|
||||
if (typeof console[method] === 'function') {
|
||||
console[method](label, value);
|
||||
console[method](label, redactSensitiveData(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ type GetProfileWithIdentity = (
|
||||
) => Promise<StalkerProfileResponse>;
|
||||
|
||||
describe('StalkerSessionService identity payloads', () => {
|
||||
const portalUrl = 'https://portal.example.com/stalker_portal/server/load.php';
|
||||
const portalUrl =
|
||||
'https://portal.example.com/stalker_portal/server/load.php';
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
let service: StalkerSessionService;
|
||||
@@ -56,6 +57,10 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
service = TestBed.inject(StalkerSessionService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('omits SN, device IDs, and signatures from get_profile when identity is blank', async () => {
|
||||
const getProfile =
|
||||
service.getProfile as unknown as GetProfileWithIdentity;
|
||||
@@ -180,6 +185,34 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
expect(handshakePayload.serialNumber).toBe('CUSTOMSN123');
|
||||
});
|
||||
|
||||
it('does not log credentials from portal request errors', async () => {
|
||||
const token = 'stalker-error-token-secret';
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
dataService.sendIpcEvent.mockRejectedValue(
|
||||
new Error(
|
||||
`Request failed: https://portal.example/api?token=${token}&action=get_profile`
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getProfile(portalUrl, macAddress, token, {}, 'random-1')
|
||||
).rejects.toThrow('Request failed');
|
||||
|
||||
const output = consoleError.mock.calls
|
||||
.flatMap((call) =>
|
||||
call.map((value) =>
|
||||
value instanceof Error
|
||||
? `${value.message}\n${value.stack ?? ''}`
|
||||
: JSON.stringify(value)
|
||||
)
|
||||
)
|
||||
.join('\n');
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
function lastStalkerPayload(): {
|
||||
params: Record<string, unknown>;
|
||||
serialNumber?: string;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
createDevLogger,
|
||||
Playlist,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
getStalkerPortalIdentityFromPlaylist,
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
@@ -91,7 +88,7 @@ interface StalkerAuthConfirmationResponse {
|
||||
})
|
||||
export class StalkerSessionService {
|
||||
private dataService = inject(DataService);
|
||||
private readonly debugLog = createDevLogger('StalkerSession');
|
||||
private readonly logger = createLogger('StalkerSession');
|
||||
|
||||
// In-memory token cache for current session (keyed by playlist ID)
|
||||
private tokenCache = new Map<string, string>();
|
||||
@@ -234,7 +231,7 @@ export class StalkerSessionService {
|
||||
);
|
||||
} catch (error) {
|
||||
// Keep failures non-fatal; next interval can recover after token refresh.
|
||||
console.warn('[StalkerSession] Watchdog ping failed:', error);
|
||||
this.logger.warn('Watchdog ping failed:', error);
|
||||
} finally {
|
||||
this.watchdogInFlight.delete(playlistId);
|
||||
}
|
||||
@@ -281,10 +278,10 @@ export class StalkerSessionService {
|
||||
};
|
||||
}
|
||||
|
||||
console.error('[StalkerSession] No token in response');
|
||||
this.logger.error('No token in response');
|
||||
throw new Error('Handshake failed: No token received');
|
||||
} catch (error) {
|
||||
console.error('[StalkerSession] Handshake error:', error);
|
||||
this.logger.error('Handshake error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -364,7 +361,7 @@ export class StalkerSessionService {
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[StalkerSession] Get profile error:', error);
|
||||
this.logger.error('Get profile error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -404,7 +401,7 @@ export class StalkerSessionService {
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[StalkerSession] do_auth error:', error);
|
||||
this.logger.error('do_auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -447,7 +444,7 @@ export class StalkerSessionService {
|
||||
profileResponse.js.msg ||
|
||||
profileResponse.js.block_msg ||
|
||||
'Unknown profile error';
|
||||
console.error('[StalkerSession] Profile error:', errorMsg);
|
||||
this.logger.error('Profile error:', errorMsg);
|
||||
throw new Error(`Profile error: ${errorMsg}`);
|
||||
}
|
||||
|
||||
@@ -457,7 +454,7 @@ export class StalkerSessionService {
|
||||
};
|
||||
} catch (error) {
|
||||
// Profile fetch failed - this is a real error, propagate it
|
||||
console.error('[StalkerSession] Profile fetch failed:', error);
|
||||
this.logger.error('Profile fetch failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -487,14 +484,14 @@ export class StalkerSessionService {
|
||||
// This prevents race conditions when multiple resources request a token simultaneously
|
||||
const pendingPromise = this.pendingAuth.get(playlist._id);
|
||||
if (pendingPromise) {
|
||||
this.debugLog('Waiting for pending authentication...');
|
||||
this.logger.debug('Waiting for pending authentication...');
|
||||
return pendingPromise;
|
||||
}
|
||||
|
||||
// No cached token - need to do full authentication (handshake + get_profile)
|
||||
// Don't trust stored tokens as they may be from a different session
|
||||
if (!playlist.portalUrl || !playlist.macAddress) {
|
||||
console.error('[StalkerSession] Missing portal URL or MAC address');
|
||||
this.logger.error('Missing portal URL or MAC address');
|
||||
throw new Error('Portal URL and MAC address are required');
|
||||
}
|
||||
const portalUrl = playlist.portalUrl;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
EpgItem,
|
||||
XtreamCategory,
|
||||
@@ -78,6 +79,7 @@ interface EpgResponse {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class XtreamApiService {
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly logger = createLogger('XtreamApiService');
|
||||
|
||||
async cancelSession(sessionId: string): Promise<boolean> {
|
||||
if (
|
||||
@@ -91,7 +93,7 @@ export class XtreamApiService {
|
||||
const result = await window.electron.xtreamCancelSession(sessionId);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel Xtream session:', error);
|
||||
this.logger.error('Failed to cancel Xtream session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
displayName: 'shared-logging',
|
||||
preset: '../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
|
||||
],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
coverageDirectory: '../../../coverage/libs/shared/logging',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "shared-logging",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/logging/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:shared-runtime", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/logging/jest.config.ts",
|
||||
"tsConfig": "libs/shared/logging/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
REDACTED_VALUE,
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
export type { RedactionOptions } from './lib/redact-sensitive-data';
|
||||
@@ -0,0 +1,357 @@
|
||||
import { REDACTED_VALUE, redactSensitiveData } from './redact-sensitive-data';
|
||||
|
||||
const TEST_SECRETS = [
|
||||
'settings-api-key-secret',
|
||||
'nested-user-secret',
|
||||
'nested-password-secret',
|
||||
'nested-token-secret',
|
||||
'nested-auth-secret',
|
||||
'nested-mac-secret',
|
||||
'query-password-secret',
|
||||
'query-token-secret',
|
||||
];
|
||||
|
||||
function serialized(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
describe('redactSensitiveData', () => {
|
||||
it('recursively redacts credentials while retaining diagnostic fields', () => {
|
||||
const input = {
|
||||
operation: 'get_profile',
|
||||
settings: { tmdb: { apiKey: TEST_SECRETS[0] } },
|
||||
params: {
|
||||
username: TEST_SECRETS[1],
|
||||
PASSWORD: TEST_SECRETS[2],
|
||||
access_token: TEST_SECRETS[3],
|
||||
headers: { Authorization: `Bearer ${TEST_SECRETS[4]}` },
|
||||
macAddress: TEST_SECRETS[5],
|
||||
},
|
||||
};
|
||||
|
||||
const result = redactSensitiveData(input);
|
||||
const output = serialized(result);
|
||||
|
||||
for (const secret of TEST_SECRETS) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(result).toEqual({
|
||||
operation: 'get_profile',
|
||||
settings: { tmdb: { apiKey: REDACTED_VALUE } },
|
||||
params: {
|
||||
username: REDACTED_VALUE,
|
||||
PASSWORD: REDACTED_VALUE,
|
||||
access_token: REDACTED_VALUE,
|
||||
headers: { Authorization: REDACTED_VALUE },
|
||||
macAddress: REDACTED_VALUE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts Stalker identity credentials while retaining request diagnostics', () => {
|
||||
const identitySecrets = {
|
||||
sn: 'stalker-sn-secret',
|
||||
serialNumber: 'stalker-serial-number-secret',
|
||||
device_id: 'stalker-device-id-secret',
|
||||
deviceId1: 'stalker-device-id1-secret',
|
||||
device_id2: 'stalker-device-id2-secret',
|
||||
signature: 'stalker-signature-secret',
|
||||
signature1: 'stalker-signature1-secret',
|
||||
signature2: 'stalker-signature2-secret',
|
||||
stalkerSerialNumber: 'playlist-stalker-serial-number-secret',
|
||||
stalkerDeviceId1: 'playlist-stalker-device-id1-secret',
|
||||
stalkerDeviceId2: 'playlist-stalker-device-id2-secret',
|
||||
stalkerSignature1: 'playlist-stalker-signature1-secret',
|
||||
stalkerSignature2: 'playlist-stalker-signature2-secret',
|
||||
prehash: 'stalker-prehash-secret',
|
||||
};
|
||||
|
||||
const result = redactSensitiveData({
|
||||
action: 'get_profile',
|
||||
requestId: 'stalker-request-id',
|
||||
params: identitySecrets,
|
||||
});
|
||||
const output = serialized(result);
|
||||
|
||||
for (const secret of Object.values(identitySecrets)) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('get_profile');
|
||||
expect(output).toContain('stalker-request-id');
|
||||
});
|
||||
|
||||
it('redacts credentials embedded in URL, URLSearchParams, errors, and serialized strings', () => {
|
||||
const url = new URL(
|
||||
`https://user:pass@example.com/live?password=${TEST_SECRETS[6]}&token=${TEST_SECRETS[7]}&action=get_live_streams`
|
||||
);
|
||||
const params = new URLSearchParams({
|
||||
authorization: TEST_SECRETS[4],
|
||||
category: 'news',
|
||||
});
|
||||
const error = new Error(
|
||||
`Request failed: https://example.com/api?token=${TEST_SECRETS[3]}&action=profile`
|
||||
);
|
||||
const json = JSON.stringify({
|
||||
refreshToken: TEST_SECRETS[3],
|
||||
status: 401,
|
||||
});
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData({ url, params, error, json })
|
||||
);
|
||||
|
||||
for (const secret of TEST_SECRETS) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('action=get_live_streams');
|
||||
expect(output).toContain('category=news');
|
||||
expect(output).toContain('status');
|
||||
expect(output).toContain('401');
|
||||
});
|
||||
|
||||
it('redacts credentials in non-HTTP stream URL strings', () => {
|
||||
const username = 'rtsp-user-secret';
|
||||
const password = 'rtsp-password-secret';
|
||||
const token = 'rtmp-token-secret';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData([
|
||||
`rtsp://${username}:${password}@stream.example/live?token=${token}&channel=news`,
|
||||
`Playback failed: rtmp://stream.example/live?password=${password}&channel=sports`,
|
||||
])
|
||||
);
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('channel=news');
|
||||
expect(output).toContain('channel=sports');
|
||||
});
|
||||
|
||||
it('redacts a credential from a single-parameter string', () => {
|
||||
const password = 'single-param-password-secret';
|
||||
const token = 'single-param-token-secret';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData([
|
||||
`password=${password}`,
|
||||
`token=${token}`,
|
||||
'operation=get_profile',
|
||||
])
|
||||
);
|
||||
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
it('redacts credentials embedded in diagnostic text', () => {
|
||||
const token = 'diagnostic-token-secret';
|
||||
const authorization = 'diagnostic-authorization-secret';
|
||||
const authorizationAssignment =
|
||||
'diagnostic-authorization-assignment-secret';
|
||||
const stalkerDeviceId = 'diagnostic-stalker-device-id-secret';
|
||||
const diagnostic = [
|
||||
`Request failed: token=${token}&action=get_profile`,
|
||||
`Upstream response Authorization: Bearer ${authorization}; status=401`,
|
||||
`Retrying with authorization=Bearer ${authorizationAssignment}, attempt=2`,
|
||||
`Identity rejected: stalkerDeviceId1: ${stalkerDeviceId}; action=handshake`,
|
||||
];
|
||||
|
||||
const output = serialized(redactSensitiveData(diagnostic));
|
||||
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).not.toContain(authorization);
|
||||
expect(output).not.toContain(authorizationAssignment);
|
||||
expect(output).not.toContain(stalkerDeviceId);
|
||||
expect(output).toContain('get_profile');
|
||||
expect(output).toContain('status=401');
|
||||
expect(output).toContain('attempt=2');
|
||||
expect(output).toContain('action=handshake');
|
||||
});
|
||||
|
||||
it('does not repeat a redacted Error message secret in its stack', () => {
|
||||
const secret = 'error-stack-password-secret';
|
||||
const error = new Error(`password=${secret}&operation=get_profile`);
|
||||
|
||||
const output = serialized(redactSensitiveData(error));
|
||||
|
||||
expect(output).not.toContain(secret);
|
||||
expect(output).toContain(encodeURIComponent(REDACTED_VALUE));
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
it('redacts credentials nested inside non-sensitive query values', () => {
|
||||
const nestedUrl = `https://identity.example/callback?token=${TEST_SECRETS[3]}&step=authorize`;
|
||||
const url = new URL('https://example.com/portal');
|
||||
url.searchParams.set('redirect', nestedUrl);
|
||||
url.searchParams.set(
|
||||
'payload',
|
||||
JSON.stringify({ password: TEST_SECRETS[2], action: 'profile' })
|
||||
);
|
||||
|
||||
const output = serialized(redactSensitiveData(url));
|
||||
|
||||
expect(output).not.toContain(TEST_SECRETS[2]);
|
||||
expect(output).not.toContain(TEST_SECRETS[3]);
|
||||
expect(output).toContain('authorize');
|
||||
expect(output).toContain('profile');
|
||||
});
|
||||
|
||||
it('redacts credentials from URL fragments while retaining diagnostics', () => {
|
||||
const token = 'fragment-token-secret';
|
||||
const url = new URL(
|
||||
`https://example.com/callback#access_token=${token}&state=diagnostic`
|
||||
);
|
||||
|
||||
const output = serialized(redactSensitiveData(url));
|
||||
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('state=diagnostic');
|
||||
});
|
||||
|
||||
it('redacts credentials from Map keys while retaining values', () => {
|
||||
const username = 'map-key-user-secret';
|
||||
const password = 'map-key-password-secret';
|
||||
const token = 'map-key-token-secret';
|
||||
const requests = new Map([
|
||||
[
|
||||
`https://example.com/live/${username}/${password}/101.ts?token=${token}`,
|
||||
{ status: 200 },
|
||||
],
|
||||
]);
|
||||
|
||||
const output = serialized(redactSensitiveData(requests));
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('101.ts');
|
||||
expect(output).toContain('"status":200');
|
||||
});
|
||||
|
||||
it('redacts Map values selected by sensitive keys', () => {
|
||||
const authorization = 'map-authorization-secret';
|
||||
const password = 'map-password-secret';
|
||||
const headers = new Map([
|
||||
['Authorization', `Bearer ${authorization}`],
|
||||
['password', password],
|
||||
['requestId', 'diagnostic-request-id'],
|
||||
]);
|
||||
|
||||
const result = redactSensitiveData(headers);
|
||||
const output = serialized(result);
|
||||
|
||||
expect(output).not.toContain(authorization);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).toContain('diagnostic-request-id');
|
||||
expect(result).toEqual({
|
||||
Authorization: REDACTED_VALUE,
|
||||
password: REDACTED_VALUE,
|
||||
requestId: 'diagnostic-request-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts Xtream credentials in playback URL paths', () => {
|
||||
const username = 'xtream-path-user-secret';
|
||||
const password = 'xtream-path-password-secret';
|
||||
const urls = [
|
||||
new URL(
|
||||
`https://example.com/base/live/${username}/${password}/101.ts`
|
||||
),
|
||||
new URL(
|
||||
`https://example.com/movie/${username}/${password}/202.mkv`
|
||||
),
|
||||
new URL(
|
||||
`https://example.com/series/${username}/${password}/303.mp4`
|
||||
),
|
||||
new URL(
|
||||
`https://example.com/timeshift/${username}/${password}/60/2026-07-18:12-00/404.ts`
|
||||
),
|
||||
];
|
||||
const embedded = `Playback failed for https://example.com/live/${username}/${password}/505.m3u8`;
|
||||
const ordinaryLiveUrl = 'https://example.com/live/channel.m3u8';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData({ urls, embedded, ordinaryLiveUrl })
|
||||
);
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).toContain('101.ts');
|
||||
expect(output).toContain('202.mkv');
|
||||
expect(output).toContain('303.mp4');
|
||||
expect(output).toContain('404.ts');
|
||||
expect(output).toContain('505.m3u8');
|
||||
expect(output).toContain(ordinaryLiveUrl);
|
||||
});
|
||||
|
||||
it('redacts Xtream path credentials when no resource segment follows', () => {
|
||||
const username = 'terminal-xtream-user-secret';
|
||||
const password = 'terminal-xtream-password-secret';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData(
|
||||
new URL(`https://example.com/live/${username}/${password}`)
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
});
|
||||
|
||||
it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => {
|
||||
const input: Record<string, unknown> = {
|
||||
status: 'ok',
|
||||
password: TEST_SECRETS[2],
|
||||
items: [1, 2, 3, 4],
|
||||
long: 'abcdefghij',
|
||||
nested: { level: { value: 'too deep' } },
|
||||
extraA: 'a',
|
||||
extraB: 'b',
|
||||
};
|
||||
input['self'] = input;
|
||||
const originalItems = input['items'];
|
||||
|
||||
const result = redactSensitiveData(input, {
|
||||
maxArrayItems: 2,
|
||||
maxDepth: 2,
|
||||
maxObjectKeys: 6,
|
||||
maxStringLength: 8,
|
||||
});
|
||||
|
||||
expect(input['password']).toBe(TEST_SECRETS[2]);
|
||||
expect(input['items']).toBe(originalItems);
|
||||
expect(result).not.toBe(input);
|
||||
expect(() => serialized(result)).not.toThrow();
|
||||
expect(serialized(result)).not.toContain(TEST_SECRETS[2]);
|
||||
expect(serialized(result)).toContain('[Truncated');
|
||||
});
|
||||
|
||||
it('serializes invalid dates without throwing', () => {
|
||||
const invalidDate = new Date(Number.NaN);
|
||||
|
||||
expect(() => redactSensitiveData(invalidDate)).not.toThrow();
|
||||
expect(redactSensitiveData([invalidDate])).toEqual(['[Invalid Date]']);
|
||||
});
|
||||
|
||||
it('preserves repeated non-circular references while still redacting them', () => {
|
||||
const shared = {
|
||||
operation: 'get_profile',
|
||||
password: TEST_SECRETS[2],
|
||||
};
|
||||
|
||||
const result = redactSensitiveData({ first: shared, second: shared });
|
||||
|
||||
expect(result).toEqual({
|
||||
first: {
|
||||
operation: 'get_profile',
|
||||
password: REDACTED_VALUE,
|
||||
},
|
||||
second: {
|
||||
operation: 'get_profile',
|
||||
password: REDACTED_VALUE,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
export const REDACTED_VALUE = '[Redacted]';
|
||||
|
||||
const CIRCULAR_VALUE = '[Circular]';
|
||||
const MAX_DEPTH_VALUE = '[MaxDepth]';
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_MAX_ARRAY_ITEMS = 50;
|
||||
const DEFAULT_MAX_OBJECT_KEYS = 50;
|
||||
const DEFAULT_MAX_STRING_LENGTH = 2_000;
|
||||
|
||||
const SENSITIVE_KEY_NAMES = new Set([
|
||||
'apikey',
|
||||
'auth',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'credentials',
|
||||
'deviceid',
|
||||
'deviceid2',
|
||||
'login',
|
||||
'mac',
|
||||
'macaddress',
|
||||
'mpvplayerarguments',
|
||||
'passwd',
|
||||
'password',
|
||||
'pwd',
|
||||
'secret',
|
||||
'setcookie',
|
||||
'signature',
|
||||
'signature2',
|
||||
'sn',
|
||||
'token',
|
||||
'username',
|
||||
'vlcplayerarguments',
|
||||
]);
|
||||
|
||||
const SENSITIVE_KEY_SUFFIXES = [
|
||||
'apikey', 'authorization', 'cookie',
|
||||
'deviceid', 'deviceid1', 'deviceid2',
|
||||
'macaddress', 'passwd', 'password', 'prehash',
|
||||
'serialnumber', 'signature', 'signature1', 'signature2',
|
||||
'secret', 'token', 'username',
|
||||
];
|
||||
|
||||
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
|
||||
'live',
|
||||
'movie',
|
||||
'series',
|
||||
'timeshift',
|
||||
]);
|
||||
|
||||
export interface RedactionOptions {
|
||||
maxDepth?: number;
|
||||
maxArrayItems?: number;
|
||||
maxObjectKeys?: number;
|
||||
maxStringLength?: number;
|
||||
}
|
||||
|
||||
interface ResolvedRedactionOptions {
|
||||
maxDepth: number;
|
||||
maxArrayItems: number;
|
||||
maxObjectKeys: number;
|
||||
maxStringLength: number;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = normalizeKey(key);
|
||||
return (
|
||||
SENSITIVE_KEY_NAMES.has(normalized) ||
|
||||
SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOptions(options: RedactionOptions): ResolvedRedactionOptions {
|
||||
return {
|
||||
maxDepth: Math.max(0, options.maxDepth ?? DEFAULT_MAX_DEPTH),
|
||||
maxArrayItems: Math.max(
|
||||
0,
|
||||
options.maxArrayItems ?? DEFAULT_MAX_ARRAY_ITEMS
|
||||
),
|
||||
maxObjectKeys: Math.max(
|
||||
0,
|
||||
options.maxObjectKeys ?? DEFAULT_MAX_OBJECT_KEYS
|
||||
),
|
||||
maxStringLength: Math.max(
|
||||
0,
|
||||
options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function truncateString(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const omitted = value.length - maxLength;
|
||||
return `${value.slice(0, maxLength)}[Truncated ${omitted} chars]`;
|
||||
}
|
||||
|
||||
function redactSearchParams(
|
||||
params: URLSearchParams,
|
||||
sanitizeValue: (value: string) => string
|
||||
): URLSearchParams {
|
||||
const redacted = new URLSearchParams();
|
||||
|
||||
params.forEach((value, key) => {
|
||||
redacted.append(
|
||||
key,
|
||||
isSensitiveKey(key) ? REDACTED_VALUE : sanitizeValue(value)
|
||||
);
|
||||
});
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactUrl(
|
||||
value: URL,
|
||||
sanitizeValue: (value: string) => string
|
||||
): string {
|
||||
const redacted = new URL(value.toString());
|
||||
|
||||
if (redacted.username) {
|
||||
redacted.username = REDACTED_VALUE;
|
||||
}
|
||||
if (redacted.password) {
|
||||
redacted.password = REDACTED_VALUE;
|
||||
}
|
||||
|
||||
const pathSegments = redacted.pathname.split('/');
|
||||
for (let index = 0; index < pathSegments.length - 2; index += 1) {
|
||||
if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) {
|
||||
pathSegments[index + 1] = REDACTED_VALUE;
|
||||
pathSegments[index + 2] = REDACTED_VALUE;
|
||||
}
|
||||
}
|
||||
redacted.pathname = pathSegments.join('/');
|
||||
|
||||
const search = redactSearchParams(
|
||||
redacted.searchParams,
|
||||
sanitizeValue
|
||||
).toString();
|
||||
redacted.search = search ? `?${search}` : '';
|
||||
|
||||
const fragment = redacted.hash.slice(1);
|
||||
if (looksLikeSearchParams(fragment)) {
|
||||
const hash = redactSearchParams(
|
||||
new URLSearchParams(fragment),
|
||||
sanitizeValue
|
||||
).toString();
|
||||
redacted.hash = hash ? `#${hash}` : '';
|
||||
}
|
||||
|
||||
return redacted.toString();
|
||||
}
|
||||
|
||||
function redactUrlStrings(
|
||||
value: string,
|
||||
sanitizeValue: (value: string) => string
|
||||
): string {
|
||||
return value.replace(
|
||||
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
|
||||
(candidate) => {
|
||||
try {
|
||||
return redactUrl(new URL(candidate), sanitizeValue);
|
||||
} catch {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function redactEmbeddedSensitivePairs(value: string): string {
|
||||
const redactedAssignments = value.replace(
|
||||
/\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu,
|
||||
(match, key: string, separator: string) =>
|
||||
isSensitiveKey(key)
|
||||
? `${key}${separator}${REDACTED_VALUE}`
|
||||
: match
|
||||
);
|
||||
return redactedAssignments.replace(
|
||||
/\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu,
|
||||
(_match, key: string, separator: string) =>
|
||||
`${key}${separator}${REDACTED_VALUE}`
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeSearchParams(value: string): boolean {
|
||||
return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(value);
|
||||
}
|
||||
|
||||
export function redactSensitiveData(
|
||||
value: unknown,
|
||||
options: RedactionOptions = {}
|
||||
): unknown {
|
||||
const resolved = resolveOptions(options);
|
||||
const seen = new WeakSet<object>();
|
||||
const visitString = (input: string, depth: number): string => {
|
||||
const trimmed = input.trim();
|
||||
if (depth >= resolved.maxDepth) {
|
||||
return MAX_DEPTH_VALUE;
|
||||
}
|
||||
if (
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
return truncateString(
|
||||
JSON.stringify(visit(JSON.parse(trimmed), depth + 1)),
|
||||
resolved.maxStringLength
|
||||
);
|
||||
} catch {
|
||||
// Keep processing malformed or non-JSON diagnostic strings.
|
||||
}
|
||||
}
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed)) {
|
||||
try {
|
||||
return truncateString(
|
||||
redactUrl(new URL(trimmed), (entry) =>
|
||||
visitString(entry, depth + 1)
|
||||
),
|
||||
resolved.maxStringLength
|
||||
);
|
||||
} catch {
|
||||
// Continue with embedded URL handling for malformed URLs.
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikeSearchParams(trimmed)) {
|
||||
return truncateString(
|
||||
redactSearchParams(new URLSearchParams(trimmed), (entry) =>
|
||||
visitString(entry, depth + 1)
|
||||
).toString(),
|
||||
resolved.maxStringLength
|
||||
);
|
||||
}
|
||||
|
||||
const redactedText = redactEmbeddedSensitivePairs(input);
|
||||
return truncateString(
|
||||
redactUrlStrings(redactedText, (entry) =>
|
||||
visitString(entry, depth + 1)
|
||||
),
|
||||
resolved.maxStringLength
|
||||
);
|
||||
};
|
||||
|
||||
const visitObject = (
|
||||
input: Record<string, unknown>,
|
||||
depth: number
|
||||
): Record<string, unknown> => {
|
||||
const output: Record<string, unknown> = {};
|
||||
const keys = Object.keys(input);
|
||||
|
||||
for (const key of keys.slice(0, resolved.maxObjectKeys)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
output[key] = REDACTED_VALUE;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
output[key] = visit(input[key], depth + 1);
|
||||
} catch {
|
||||
output[key] = '[Unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length > resolved.maxObjectKeys) {
|
||||
output['__truncatedKeys'] = keys.length - resolved.maxObjectKeys;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const visitError = (
|
||||
error: Error,
|
||||
depth: number
|
||||
): Record<string, unknown> => {
|
||||
const output = visitObject(
|
||||
error as Error & Record<string, unknown>,
|
||||
depth
|
||||
);
|
||||
output['name'] = visitString(error.name, depth + 1);
|
||||
output['message'] = visitString(error.message, depth + 1);
|
||||
if (error.stack) {
|
||||
const [, ...stackFrames] = error.stack.split('\n');
|
||||
output['stack'] = visitString(
|
||||
[`${output['name']}: ${output['message']}`, ...stackFrames].join(
|
||||
'\n'
|
||||
),
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
if ('cause' in error) {
|
||||
output['cause'] = visit(error.cause, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const visit = (input: unknown, depth: number): unknown => {
|
||||
if (
|
||||
input == null ||
|
||||
typeof input === 'boolean' ||
|
||||
typeof input === 'number'
|
||||
) {
|
||||
return input;
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
return visitString(input, depth);
|
||||
}
|
||||
if (typeof input === 'bigint') {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === 'symbol') {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === 'function') {
|
||||
return undefined;
|
||||
}
|
||||
if (depth >= resolved.maxDepth) {
|
||||
return MAX_DEPTH_VALUE;
|
||||
}
|
||||
|
||||
const object = input as object;
|
||||
if (seen.has(object)) {
|
||||
return CIRCULAR_VALUE;
|
||||
}
|
||||
seen.add(object);
|
||||
|
||||
try {
|
||||
if (input instanceof URL) {
|
||||
return redactUrl(input, (entry) =>
|
||||
visitString(entry, depth + 1)
|
||||
);
|
||||
}
|
||||
if (input instanceof URLSearchParams) {
|
||||
return redactSearchParams(input, (entry) =>
|
||||
visitString(entry, depth + 1)
|
||||
).toString();
|
||||
}
|
||||
if (input instanceof Error) {
|
||||
return visitError(input, depth);
|
||||
}
|
||||
if (input instanceof Date) {
|
||||
return Number.isNaN(input.getTime())
|
||||
? '[Invalid Date]'
|
||||
: input.toISOString();
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
const output = input
|
||||
.slice(0, resolved.maxArrayItems)
|
||||
.map((entry) => visit(entry, depth + 1));
|
||||
if (input.length > resolved.maxArrayItems) {
|
||||
output.push(
|
||||
`[Truncated ${
|
||||
input.length - resolved.maxArrayItems
|
||||
} items]`
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (input instanceof Map) {
|
||||
const entries: Record<string, unknown> = {};
|
||||
const mapEntries = Array.from(input).slice(
|
||||
0,
|
||||
resolved.maxObjectKeys
|
||||
);
|
||||
for (const [key, entry] of mapEntries) {
|
||||
const stringKey = String(key);
|
||||
const redactedKey = visitString(stringKey, depth + 1);
|
||||
entries[redactedKey] =
|
||||
isSensitiveKey(stringKey) &&
|
||||
!/[/:?=&]/u.test(stringKey)
|
||||
? REDACTED_VALUE
|
||||
: visit(entry, depth + 1);
|
||||
}
|
||||
if (input.size > resolved.maxObjectKeys) {
|
||||
entries['__truncatedKeys'] =
|
||||
input.size - resolved.maxObjectKeys;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
if (input instanceof Set) {
|
||||
return visit(Array.from(input), depth);
|
||||
}
|
||||
|
||||
return visitObject(input as Record<string, unknown>, depth);
|
||||
} finally {
|
||||
seen.delete(object);
|
||||
}
|
||||
};
|
||||
|
||||
return visit(value, 0);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.lib.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -59,7 +59,10 @@ describe('embedded MPV format utilities', () => {
|
||||
expect(volumeLabel(0.755)).toBe('Volume 76%');
|
||||
});
|
||||
|
||||
it('rounds host bounds and keeps minimum native view dimensions', () => {
|
||||
it('preserves fractional host edges and keeps minimum native view dimensions', () => {
|
||||
// Rounding happens once in the main process, after CSS→native
|
||||
// scaling — pre-rounded edges would drift by up to 1px per scale
|
||||
// factor on scaled displays.
|
||||
const host = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10.4,
|
||||
@@ -70,8 +73,8 @@ describe('embedded MPV format utilities', () => {
|
||||
} as HTMLElement;
|
||||
|
||||
expect(measureBounds(host)).toEqual({
|
||||
x: 10,
|
||||
y: 21,
|
||||
x: 10.4,
|
||||
y: 20.6,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
@@ -120,12 +120,19 @@ export function persistVolume(value: number): void {
|
||||
localStorage.setItem('volume', String(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the host element in CSS pixels without rounding. The main process
|
||||
* converts these bounds to native units (page zoom × display scale) and
|
||||
* rounds exactly once, after scaling — pre-rounding here would bake up to
|
||||
* ±0.5px of CSS error that the scale factor then amplifies into visible
|
||||
* off-by-one seams (e.g. a 10.49px edge at 200% renders at 21px, not 20px).
|
||||
*/
|
||||
export function measureBounds(host: HTMLElement): EmbeddedMpvBounds {
|
||||
const rect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: Math.max(1, rect.width),
|
||||
height: Math.max(1, rect.height),
|
||||
};
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
EmbeddedMpvSession,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
|
||||
|
||||
/**
|
||||
* Moving the window to a display with a different scale (or changing the
|
||||
* page zoom) rescales the CSS→native mapping the backend applies to bounds,
|
||||
* without necessarily resizing the host element. The controller watches
|
||||
* devicePixelRatio through a re-armed matchMedia query and re-syncs bounds
|
||||
* when it changes (#1145).
|
||||
*/
|
||||
describe('EmbeddedMpvSessionController devicePixelRatio watch', () => {
|
||||
class FakeMediaQueryList {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(readonly media: string) {}
|
||||
|
||||
addEventListener(_type: 'change', listener: () => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(_type: 'change', listener: () => void): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
get listenerCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
}
|
||||
|
||||
let electron: {
|
||||
platform: string;
|
||||
getEmbeddedMpvSupport: jest.Mock;
|
||||
prepareEmbeddedMpv: jest.Mock;
|
||||
createEmbeddedMpvSession: jest.Mock;
|
||||
loadEmbeddedMpvPlayback: jest.Mock;
|
||||
disposeEmbeddedMpvSession: jest.Mock;
|
||||
setEmbeddedMpvBounds: jest.Mock;
|
||||
onEmbeddedMpvSessionUpdate: jest.Mock;
|
||||
};
|
||||
let mediaQueries: FakeMediaQueryList[];
|
||||
|
||||
beforeEach(() => {
|
||||
electron = {
|
||||
platform: 'win32',
|
||||
getEmbeddedMpvSupport: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ supported: true, platform: 'win32' }),
|
||||
prepareEmbeddedMpv: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ supported: true, platform: 'win32' }),
|
||||
createEmbeddedMpvSession: jest
|
||||
.fn()
|
||||
.mockResolvedValue(createSession()),
|
||||
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
|
||||
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
|
||||
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
|
||||
onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electron,
|
||||
});
|
||||
|
||||
mediaQueries = [];
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: (media: string) => {
|
||||
const query = new FakeMediaQueryList(media);
|
||||
mediaQueries.push(query);
|
||||
return query;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
value: class MockResizeObserver {
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(0), 0),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: (handle: number) => window.clearTimeout(handle),
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [EmbeddedMpvSessionController],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('re-syncs bounds and re-arms the query when devicePixelRatio changes', async () => {
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
const teardown = controller.startSession(
|
||||
createHost(),
|
||||
createPlayback(),
|
||||
0.5
|
||||
);
|
||||
await waitFor(
|
||||
() => controller.sessionId() === 'mpv-1',
|
||||
'session to start'
|
||||
);
|
||||
|
||||
expect(mediaQueries.length).toBe(1);
|
||||
expect(mediaQueries[0].media).toBe('(resolution: 1dppx)');
|
||||
electron.setEmbeddedMpvBounds.mockClear();
|
||||
|
||||
// Simulate a move to a 150%-scaled display.
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value: 1.5,
|
||||
});
|
||||
mediaQueries[0].fire();
|
||||
await waitFor(
|
||||
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
|
||||
'bounds re-sync after dPR change'
|
||||
);
|
||||
|
||||
// The stale query is released and a new one tracks the new ratio.
|
||||
expect(mediaQueries[0].listenerCount).toBe(0);
|
||||
expect(mediaQueries.length).toBe(2);
|
||||
expect(mediaQueries[1].media).toBe('(resolution: 1.5dppx)');
|
||||
|
||||
// A second display change must fire through the re-armed query.
|
||||
electron.setEmbeddedMpvBounds.mockClear();
|
||||
mediaQueries[1].fire();
|
||||
await waitFor(
|
||||
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
|
||||
'bounds re-sync after second dPR change'
|
||||
);
|
||||
|
||||
teardown();
|
||||
expect(mediaQueries[mediaQueries.length - 1].listenerCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function createHost(): HTMLElement {
|
||||
return {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 640,
|
||||
height: 360,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
}
|
||||
|
||||
function createPlayback(): ResolvedPortalPlayback {
|
||||
return {
|
||||
streamUrl: 'https://example.com/movie.mp4',
|
||||
title: 'Example Movie',
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(): EmbeddedMpvSession {
|
||||
return {
|
||||
id: 'mpv-1',
|
||||
title: 'Example Movie',
|
||||
streamUrl: 'https://example.com/movie.mp4',
|
||||
status: 'playing',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 0.5,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
playbackSpeed: 1,
|
||||
aspectOverride: 'no',
|
||||
recording: { active: false },
|
||||
startedAt: '2026-07-19T00:00:00.000Z',
|
||||
updatedAt: '2026-07-19T00:00:01.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
condition: () => boolean,
|
||||
description: string
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (condition()) {
|
||||
return;
|
||||
}
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
+3
-1
@@ -141,8 +141,10 @@ describe('EmbeddedMpvSessionController', () => {
|
||||
);
|
||||
|
||||
expect(electron.prepareEmbeddedMpv).toHaveBeenCalled();
|
||||
// Fractional CSS edges stay unrounded: the main process rounds once,
|
||||
// after converting them to native units.
|
||||
expect(electron.createEmbeddedMpvSession).toHaveBeenCalledWith(
|
||||
{ x: 11, y: 21, width: 640, height: 360 },
|
||||
{ x: 10.6, y: 20.5, width: 640, height: 360 },
|
||||
'Example Movie',
|
||||
0.7
|
||||
);
|
||||
|
||||
@@ -151,6 +151,31 @@ export class EmbeddedMpvSessionController {
|
||||
window.addEventListener('resize', scheduleBoundsSync);
|
||||
window.addEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
// Page zoom and monitor DPI rescale the CSS→native-pixel mapping the
|
||||
// backend applies to these bounds. Moving the window to a display
|
||||
// with a different scale can keep the CSS layout identical (no
|
||||
// resize, no ResizeObserver), so watch devicePixelRatio through a
|
||||
// re-armed matchMedia query and re-sync when it changes.
|
||||
let detachDprWatch: (() => void) | null = null;
|
||||
const watchDevicePixelRatio = () => {
|
||||
detachDprWatch?.();
|
||||
detachDprWatch = null;
|
||||
const query = window.matchMedia?.(
|
||||
`(resolution: ${window.devicePixelRatio}dppx)`
|
||||
);
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
const onChange = () => {
|
||||
watchDevicePixelRatio();
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
query.addEventListener('change', onChange);
|
||||
detachDprWatch = () =>
|
||||
query.removeEventListener('change', onChange);
|
||||
};
|
||||
watchDevicePixelRatio();
|
||||
|
||||
const create = async () => {
|
||||
this.session.set(createLoadingSession(playback, initialVolume));
|
||||
await waitForStartupPaint();
|
||||
@@ -237,6 +262,8 @@ export class EmbeddedMpvSessionController {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', scheduleBoundsSync);
|
||||
window.removeEventListener('scroll', scheduleBoundsSync, true);
|
||||
detachDprWatch?.();
|
||||
detachDprWatch = null;
|
||||
|
||||
if (this.activeBoundsSync === scheduleBoundsSync) {
|
||||
this.activeBoundsSync = null;
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
"validationCommand": "pnpm nx test shared-interfaces",
|
||||
"e2eTags": ["@m3u", "@xtream", "@stalker"]
|
||||
},
|
||||
{
|
||||
"name": "shared-logging",
|
||||
"root": "libs/shared/logging",
|
||||
"sourceRoot": "libs/shared/logging/src",
|
||||
"validationCommand": "pnpm nx test shared-logging",
|
||||
"e2eTags": ["@electron", "@xtream", "@stalker", "@settings"]
|
||||
},
|
||||
{
|
||||
"name": "m3u-utils",
|
||||
"root": "libs/shared/m3u-utils",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"libs/shared/m3u-utils/src/index.ts"
|
||||
],
|
||||
"@iptvnator/shared/testing": ["libs/shared/testing/src/index.ts"],
|
||||
"@iptvnator/shared/logging": ["libs/shared/logging/src/index.ts"],
|
||||
"@iptvnator/services": ["libs/services/src/index.ts"],
|
||||
"@iptvnator/shared/interfaces": [
|
||||
"libs/shared/interfaces/src/index.ts"
|
||||
|
||||
Reference in new issue
Block a user