* perf(electron): let hidden and minimized windows report themselves hidden
The main window was created with backgroundThrottling: false (since #1123,
without a stated reason). Electron then keeps document.visibilityState at
"visible" for a hidden, minimized or fully covered window and never lets
Chromium throttle it, so every renderer timer, rAF and CSS transition ran at
full rate in the background, and the playback keep-awake gate, which
releases the display for a minimized window, could never see one.
Use Chromium's default. Audible media and picture-in-picture are exempt
from background throttling in Chromium, and a local check confirmed HLS
playback continues unchanged through more than six minutes minimized,
audible and muted.
Playwright's focus emulation pins every page it attaches to as visible, so
the new window-visibility E2E launches the app without Playwright and
drives it over raw CDP (electron-unautomated-launch.ts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): harden the unautomated Electron launch
Review follow-ups for the window-visibility E2E:
- Resolve `electron` in the main process through a require created from
the `node:module` builtin instead of `process.mainModule`, which only
exists when the app entry is CommonJS.
- Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM,
then SIGKILL, 5 s each, then fail instead of waiting forever.
- Surface CDP protocol errors from Runtime.evaluate instead of returning
undefined.
- Wait until the window is actually shown before hiding it. The app shows
its window on ready-to-show, and a hide() that lands earlier is undone
by that show(); this was the first-attempt failure on the macOS shard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): check the playback display lock is released while hidden
Review follow-ups for #1724:
- Add an E2E that plays the webm fixture in the unautomated launch,
records the main process's prevent-display-sleep blockers, and asserts
the keep-awake lock is taken while visible, released when the window is
hidden, and taken again when it is shown. The visibility tests alone
would still pass if the renderer gate or the bridge stopped updating
powerSaveBlocker.
- Validate CDP replies before dispatch (CodeQL
js/unvalidated-dynamic-method-call): only a numeric id with a pending
settle function is called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): skip killing an Electron that already exited
stopElectron now checks the recorded exit state before each signal and
tolerates a kill that races the exit: on Windows taskkill throws for a PID
that no longer exists. Startup cleanup can no longer replace the startup
error that explains the failure; a cleanup failure there is logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(stalker): keep the watchdog cadence while the window is hidden
With background throttling on, Chromium wakes a hidden, silent page's
timers at most once per minute after five minutes, so a portal that asks
for get_events every 30 s would see pings at half its cadence while the
window is minimized. Tick the watchdog from a dedicated worker
(createBackgroundInterval, an inline blob worker allowed by the renderer
CSP), whose timers are not subject to page throttling. It falls back to a
page setInterval where no worker is available or the worker fails to load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(stalker): keep a stopped watchdog interval stopped
A worker error that arrived after stop() started the page fallback
interval, which nothing cleared, so pings continued for an inactive
playlist. stop() now marks the interval stopped, detaches the worker
handlers, and the fallback refuses to start afterwards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(playback): forward portal Cookie/Authorization to built-in players
The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).
- request-header-overrides.service: the scoped override now carries Cookie
and Authorization, attached only to requests on the exact stream origin,
in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
drop credentials fail-closed; control characters in header values are
rejected. Chosen over session.cookies.set(): jar cookies attach only to
credentialed requests, which would force withCredentials into every engine
and break against the Access-Control-Allow-Origin:* IPTV panels send, and
jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
every built-in player: it extracts the full header set from the resolved
playback, configures the override BEFORE handing the source over (players
render only once the source exists), and clears the scoped layer on
destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
set ITV already had (they previously carried no portal headers at all);
same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
(isStalkerStreamCredentialSafe): same-host port changes and scheme
upgrades keep the portal profile (the #1158 class), a foreign host or
https->http downgrade keeps the credential-free KSPlayer profile. The
main-process fallback context uses the same predicate so
isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
create_link returns a local /stream/gated/video.mp4 that 403s without the
mac cookie + current Bearer token; new Electron e2e proves a built-in
player actually plays it (and that the gate refuses bare requests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): apply header override to Stalker radio, redact mock cookie log
Address Codex review feedback on #1335:
- The radio branch of the Stalker live layout renders the dedicated audio
player, never WebPlayerViewComponent, so the resolved portal headers were
built but never applied — an auth-gated radio stream still 403'd. The
override sync is extracted into ElectronStreamHeadersService (single owner
of the scoped override slot, with clear-only-while-owning semantics so a
destroyed consumer cannot wipe a newer consumer's override), applied by
WebPlayerViewComponent for video players and by the radio branch before
the audio element gets its URL. The service feature-detects the bridge
method so partial bridges behave like the PWA instead of throwing.
- The gated-stream mock no longer logs the raw Cookie header on 403 —
presence only, matching the Authorization logging.
- The gated scenario now serves an audio fixture for radio create_link and
the Electron e2e covers the radio path end-to-end (bare request 403s,
built-in audio player advances past the gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): claim radio header ownership before awaiting the IPC
Codex round-2 P2: leaving the radio route while the header IPC was still in
flight left the portal cookie/token installed — ngOnDestroy saw a null scope
URL (it was recorded only after the await) and could not clear the override.
Ownership is now claimed synchronously before awaiting, destroy invalidates
the pending playback continuation, and the apply's stillCurrent verdict is
honored. Regression test covers destroy-during-pending-IPC.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): carry portal headers into collection playback
Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed
resolved through StreamResolverService.resolveStalker(), which returned no
portal headers — the video path handed the header owner an empty set and
collection radio bypassed it entirely, so auth-gated streams still 403'd
from collections.
- resolveStalker() now builds the same profile as the live layout via the
shared classifier: portal-owned streams get mac cookie/Bearer token/MAG
UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer
profile (both create_link results and direct radio URLs).
- UnifiedLiveTabComponent applies the scoped override for radio before the
audio element gets its URL (ownership claimed before awaiting the IPC,
round-2 lesson), and clears it on close and destroy.
- Regression tests: resolver header profiles for portal-host and foreign
streams; unified tab radio apply-then-clear.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): release the radio override when a new selection mounts no player
Codex round-4 P2: after radio installed its credentials, selecting an item
that never mounts a player surface (external video playback, failed
resolution) left the old Cookie/Authorization installed — no
WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both
radio hosts (unified collection tab and the Stalker live layout, which has
the identical hole) now release the previously owned radio scope at the
start of every new selection; the slot-ownership semantics keep this a
no-op when another playback already owns the override. Regression test in
the live-layout spec pins the failed-selection path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): state the exact override release points
Codex round-5 P2 flagged that the media 'ended' event does not clear the
scoped override while the player stays mounted. That is deliberate, not a
gap: a mounted player still owns the session — replay or a seek into an
unbuffered range must keep working against a gated stream, and clearing on
'ended' would 403 exactly the streams this PR fixes. The credentials only
ever travel to the exact origin that issued them, and every dismount path
(channel/source change, player close/destroy, radio close, playerless
selection) releases them. The security doc and the release note now say
precisely that instead of the ambiguous "cleared when playback ends".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): fit the release note back under the 400-character cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): keep session headers on same-host redirects
Since 0.22 requestWithValidatedRedirects stripped Cookie/Authorization
whenever a redirect changed the *origin*, so a portal answering with an
http->https upgrade or a port move lost the MAC cookie and Bearer token
mid-session. Real Stalker/Ministra servers then reply with a plain-text
"Authorization failed." body: categories fail to load, create_link never
resolves, and no player receives a stream URL (#1158 regression window).
Scope credential stripping to the host instead: same-host scheme/port
redirects keep headers, basic auth, params, and request bodies; a
redirect to a different host still drops all of them, preserving the
original hardening intent (no credential leaks to third-party hosts).
Also adds the Stalker API compatibility roadmap produced by the
2026-08-01 protocol audit (.plans/, force-added like earlier plans).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(electron): strip credentials on same-host https-to-http downgrades
Review follow-up (Greptile P1 + Codex on #1322): the host-only check
kept Authorization/Cookie/basic auth/params/body when an https request
was redirected to http on the same host, replaying a TLS-obtained
session in cleartext. Treat that downgrade like a host change: strip
credentials and refuse to replay request bodies. Scheme upgrades and
port moves on the same host keep headers — the actual #1158 scenarios.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks
S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.
* perf(player): lazy-load web video players via @defer
Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.
* fix(player): remove leaked HTML video listeners on destroy
volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.
* refactor(dashboard): extract pure navigation helpers from DashboardDataService
Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.
* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)
- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
(fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
to avoid the one-frame blank/layout-shift before the chunk resolves.
* fix(security): close Electron network and download gaps
* test(downloads): cover cancellation and restart cleanup
* fix(downloads): address Greptile review gaps
* test(security): reproduce remaining Greptile findings
* fix(security): close remaining Greptile findings
* test(downloads): reproduce early database queue stall
* fix(downloads): release queue after setup failures
* test(downloads): reproduce completion queue stall
* fix(downloads): release queue after completion failures