mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge origin/master into claude/parental-control-feature-31dde2
Resolves the settings-store defaults split, the electron-conf key list and the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental lock contract is linked from the agent context map instead). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
ae5d3f49de
732 files changed
+42572
-15380
No files matched your search
@@ -1,9 +0,0 @@
|
||||
---
|
||||
type: internal
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
The dashboard's live "now playing" time range and progress bar now read
|
||||
pre-computed EPG timestamps as unix seconds, matching the rest of the EPG
|
||||
code. Today's dashboard lookups never carry those fields, so nothing changes
|
||||
on screen; this closes the gap before a future data path supplies them.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: perf
|
||||
area: database
|
||||
issues: [1292]
|
||||
---
|
||||
|
||||
Refreshing or removing a large Xtream playlist is several times faster: the
|
||||
"Removing cached content" stage and the re-import now commit around 5,000
|
||||
rows at a time instead of 100, and progress updates arrive at most ten times a
|
||||
second instead of once per batch.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: database
|
||||
issues: [1580]
|
||||
---
|
||||
|
||||
Updating directly from older versions no longer blocks playlist loading with a missing database column error. Existing sources, favorites, and playback history are preserved.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: internal
|
||||
area: deps
|
||||
---
|
||||
|
||||
Closes the four open Dependabot alerts on transitive npm dependencies via
|
||||
pinned pnpm overrides: browserslist (crash on untrusted stats), @xmldom/xmldom
|
||||
(XML fragment injection — its existing override target had itself fallen into
|
||||
the advisory range), @humanfs/node (recursive copy follows symlinks out of the
|
||||
tree) and postcss-selector-parser (AST recursion DoS).
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: downloads
|
||||
---
|
||||
|
||||
Download completed Xtream catch-up programmes as TS files from Live TV programme details. Find them in Downloads with the channel and broadcast date and play them offline. Interrupted archive downloads restart from the beginning.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: embedded-mpv
|
||||
highlight: Embedded MPV reconnects dropped streams
|
||||
---
|
||||
|
||||
The embedded MPV player now reloads a live stream that drops mid-playback on
|
||||
its own, with increasing delays and an "attempt N of 6" line instead of a
|
||||
dead error screen; a new Settings > Playback toggle turns this off. The same
|
||||
section gains an advanced field for extra libmpv options (one key=value per
|
||||
line) that applies on every engine, with a short network timeout on by default.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: embedded-mpv
|
||||
---
|
||||
|
||||
When a stream refuses the connection, the embedded MPV player (frame-copy
|
||||
engine) no longer hands the URL to yt-dlp before giving up: the failure
|
||||
shows up right away and its error no longer reads "youtube-dl failed:
|
||||
unexpected error occurred", matching the native-view engines and the
|
||||
external MPV player.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
---
|
||||
|
||||
Copy a catch-up programme’s URL from its EPG details in Xtream and supported M3U sources, including Favorites and Recent, without interrupting playback. The link can be used in an external player or download tool.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
issues: [50]
|
||||
---
|
||||
|
||||
EPG settings now include a display-only time offset, making it easy to correct provider feeds with missing or incorrect timezone information without re-importing the guide.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: epg
|
||||
issues: [1586]
|
||||
---
|
||||
|
||||
EPG guides now load when a server adds gzip compression to an already compressed XMLTV file. Sources using a single gzip layer continue to work.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
issues: [171]
|
||||
highlight: Programme guide, rebuilt
|
||||
---
|
||||
|
||||
The programme guide now shows your playlist's own channels in their order,
|
||||
with the current group or favorites one click away. Click a channel to
|
||||
switch playback while the player stays on screen above the grid;
|
||||
double-click switches and closes. Open it from the new Guide button, the
|
||||
header, the command palette, or the G key. Hide channels without EPG and
|
||||
pick comfortable or compact rows.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: epg
|
||||
---
|
||||
|
||||
Removing and saving an EPG source now clears its cached programmes, including data left by previously removed sources on restart. Other configured sources and playlist guides are preserved, and Live TV stops showing programmes from the removed source.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
---
|
||||
|
||||
The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: host-health
|
||||
issues: [1439]
|
||||
---
|
||||
|
||||
Portal recovery now waits for an active probe to finish before sending another request, even when redirects or a slow response take longer than 45 seconds. Completed and cancelled requests release the probe slot reliably.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: live-tv
|
||||
issues: [1458]
|
||||
---
|
||||
|
||||
A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: m3u
|
||||
---
|
||||
|
||||
ClearKey channels with ordinary Base64 keys now play when imported or refreshed, including JSON playlist exports that use + and / characters. Refresh an existing playlist once to apply the fix.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: m3u
|
||||
issues: [1590]
|
||||
---
|
||||
|
||||
DASH channels, including ClearKey-protected streams, now play from Recently Viewed and Favorites using the compatible built-in player. Existing imports keep their DRM keys without needing to re-import the playlist.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: m3u
|
||||
issues: [465, 1120]
|
||||
---
|
||||
|
||||
M3U URL imports now accept a custom User-Agent for providers that require it before downloading the playlist. The value is saved and reused for playlist refreshes in the desktop app and self-hosted web app.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: m3u
|
||||
---
|
||||
|
||||
Movies and episodes recognized as video files in M3U playlists now offer playback time and seeking in built-in players, even when TMDB or movie details are disabled. Seeking remains dependent on the source's support.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: migration
|
||||
issues: [1504]
|
||||
---
|
||||
|
||||
Upgrades from older desktop versions retain all sources from the legacy profile. Already-upgraded users can choose to recover missing sources without replacing current sources or settings. Migration keeps the original data and retries safely after a failed write.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: migration
|
||||
---
|
||||
|
||||
Startup now shows a theme-aware preparation screen while loading sources and checking a large programme guide. Temporary source-read failures retry automatically; if loading still fails, you can retry without restarting the app. Recovery also completes any previously failed programme-guide cleanup before opening the library.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: packaging
|
||||
---
|
||||
|
||||
AppImage builds now include an update source for AppManager, allowing it to discover new GitHub releases and download full application updates without manually configuring the repository.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
The built-in player controls now hide on their own after you click a button
|
||||
in the bar with the mouse, such as fullscreen or mute. Previously the clicked
|
||||
button kept the bar pinned on screen until you clicked the video, which also
|
||||
paused it. Applies to the shared controls in the web players and the
|
||||
Embedded MPV frame-copy engine.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
---
|
||||
|
||||
Playback errors now explain confirmed access and DRM license failures, with stream codecs, DRM systems and failure stages in the details. Copy diagnostics creates a support report without stream URLs or credentials. No extra requests are made to your provider.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Arrow keys and the ±10 s buttons in the Embedded MPV player now move by their
|
||||
full step every time. Pressing an arrow repeatedly, or holding it, used to
|
||||
advance only about a second per press because each step was computed from a
|
||||
stale position; steps are now relative seeks executed by mpv itself, so rapid
|
||||
presses add up.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
issues: [48]
|
||||
highlight: Channel list in fullscreen
|
||||
---
|
||||
|
||||
Rest the mouse on the left edge (tap it on touch) or press C while a live
|
||||
channel plays fullscreen: the channel list (M3U all/groups/favorites/recent,
|
||||
Xtream, Stalker, global favorites) slides over the video with search, so you
|
||||
can zap without leaving fullscreen. Nothing covers the video while it is
|
||||
closed. M3U playlists also zap with PageUp/PageDown.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
The fullscreen channel panel stays open under the pointer when its opening animation is delayed, instead of closing before the list appears.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
With the default shared player controls, the built-in player now stays in
|
||||
fullscreen when you switch to another episode, when the next episode starts
|
||||
automatically, and when a live channel or an alternative movie source is
|
||||
switched — previously every switch dropped back to the page. The legacy
|
||||
vendor-controls opt-out keeps its previous behavior.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top. This includes Safari’s legacy picture-in-picture mode.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [1530]
|
||||
---
|
||||
|
||||
Embedded MPV controls and programme-guide panels now follow the visual theme and stay readable in light and dark mode. Video overlays retain their contrasting dark backgrounds.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
The built-in HTML5 player now plays `.mkv`, `.webm`, `.avi`, `.mov` and other
|
||||
non-HLS video files directly instead of handing them to the HLS engine, which
|
||||
failed with a "network or provider loading error" on many Xtream episodes and
|
||||
movies. ArtPlayer and the HTML5 player now choose their engine by the same rule.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Keyboard shortcuts work again right after you click a button in the built-in
|
||||
player controls. Previously the clicked button kept the keyboard: after
|
||||
clicking fullscreen, Space left fullscreen instead of pausing, and the seek,
|
||||
volume and mute keys did nothing until you clicked the video. Applies to the
|
||||
shared controls in the web players and the Embedded MPV frame-copy engine.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
With IPTVnator's shared controls turned off, the Video.js player's keyboard
|
||||
shortcuts now work again right after you click a button in its control bar.
|
||||
Previously the clicked button kept the keyboard: after clicking fullscreen,
|
||||
Space left fullscreen instead of pausing, and the seek, volume and mute keys
|
||||
did nothing until you clicked the video.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
issues: [871]
|
||||
---
|
||||
|
||||
VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
highlight: See what a stream really is while it plays
|
||||
---
|
||||
|
||||
The player overlay now shows stream information: resolution, playback and source
|
||||
frame rates, stream and codec bitrates, audio details, buffer and dropped frames.
|
||||
Available in built-in web players and experimental frame-copy Embedded MPV.
|
||||
Unknown or unavailable values stay hidden; measured frame rates reflect dropped frames and
|
||||
stalls.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: playlist
|
||||
---
|
||||
|
||||
Desktop users can review inactive sources and delete selected ones together. The dialog checks the whole library, preselects confirmed expired or disabled accounts, and lets you keep individual sources. Busy sources are skipped, and deletion results are reported individually.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: playlist
|
||||
---
|
||||
|
||||
Desktop playlist lists now show availability for Stalker portals and M3U links alongside Xtream accounts. Checks share cached results, run in the background, and explain when a source could not be verified.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: playlists
|
||||
---
|
||||
|
||||
Opening an M3U playlist immediately after refreshing now waits for its pending save, so the channel list does not revert to the previous catalog.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
issues: [1438]
|
||||
---
|
||||
|
||||
Xtream and Stalker portals no longer enter a connection cooldown merely because several parallel requests fail in the same millisecond. This fixes false unavailability in both the desktop app and the self-hosted web version.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: portals
|
||||
---
|
||||
|
||||
Desktop settings now let you disable the temporary pause after repeated Xtream or Stalker connection failures, without restarting. Account info explains when requests are paused and offers an immediate retry.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
issues: [1520]
|
||||
---
|
||||
|
||||
Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: portals
|
||||
---
|
||||
|
||||
Live TV panels now fold in steps: hide only the categories rail and keep the
|
||||
channel list, or hide both for a player-only view. With categories hidden, the
|
||||
list header becomes a category dropdown, so switching categories stays one
|
||||
click away, and hiding both panels then bringing them back returns to the
|
||||
level you had before.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: feature
|
||||
area: shell
|
||||
issues: [1455]
|
||||
---
|
||||
|
||||
Settings → General has a new "Window on startup" option: open the desktop app
|
||||
at its last size, maximized, or fullscreen — handy on a TV or HTPC. Starting
|
||||
with `--fullscreen` forces a single fullscreen launch without changing the
|
||||
setting, and F11 now toggles fullscreen anywhere in the app.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
issues: [1543]
|
||||
---
|
||||
|
||||
Stalker Live TV search stays within the selected category in the sidebar and fullscreen panel, including channels beyond the first page. All Items searches the whole catalog, and the two search fields work independently.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
In Stalker portals, switching the Live TV or radio category in the sidebar no
|
||||
longer stops the channel that is playing. The category only changes which
|
||||
channels are listed, matching how Xtream portals and M3U playlists already
|
||||
behave.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker series with season markers such as “s02” or “2 сезон” now show the correct season in tabs and episode labels while preserving watch progress. TMDB updates no longer reset loaded episodes, and delayed portal responses cannot mix episodes from different series.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Seeking in Stalker movies and series now reaches the selected position instead of jumping forward or skipping to the next episode after resuming playback.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
issues: [1506]
|
||||
---
|
||||
|
||||
Live TV now supports keyboard movement between categories and channels, and channel lists keep scrolling after mouse selection. Channel scrollbars and resize handles are independently accessible. Movie and series details support immediate keyboard scrolling and no longer hide their scrollbars.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
---
|
||||
|
||||
Detail buttons, episode cards and episode rows now keep visible backgrounds and borders in the light theme. The grid/list switch also clearly highlights the selected view in both themes.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
issues: [1570]
|
||||
---
|
||||
|
||||
Movie and series detail pages keep their Back button visible while scrolling. Escape closes the inline player to the description, then returns to the previous view. Open menus, dialogs and fullscreen retain priority.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: web-backend
|
||||
issues: [1436]
|
||||
---
|
||||
|
||||
The self-hosted backend now checks every provider redirect and pins connections to validated addresses, preventing redirects or DNS changes from bypassing private-network restrictions. Trusted LAN access remains available through the existing opt-in.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
issues: [1513]
|
||||
---
|
||||
|
||||
Xtream Live TV in Auto can try advertised TS once when HLS initially fails with an HTTP error in a web player. The selected player and stream headers are kept. Settings explain the manual TS workaround for external players, Embedded MPV, and HLS retries that never report a terminal failure.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
issues: [1562]
|
||||
---
|
||||
|
||||
Catch-up (timeshift) from the Favorites and Recent tabs now asks the panel for the programme you clicked: the start time is rendered in the panel's own timezone instead of your computer's. The panel's timezone is remembered per source, survives restarts, and panels that report an unusual timezone name are handled through their clock.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
issues: [812, 816]
|
||||
---
|
||||
|
||||
Xtream category management now selects or deselects only matching categories while searching, preserving the visibility of all other categories. Button labels and availability reflect the search results, and the counter clearly shows the total selected.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
---
|
||||
|
||||
The explicit HTTPS and HTTP connection test now detects Xtream portals that accept HTTP when HTTPS is unavailable and fills in the working address before you save. The saved address is used for catalog updates, provider EPG and playback. Connection failures now show a more specific explanation.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
---
|
||||
|
||||
The Xtream sync panel now keeps its status text, labels and Stop sync button clearly readable in both light and dark themes.
|
||||
@@ -7,6 +7,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I
|
||||
|
||||
Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`.
|
||||
|
||||
For imagegen announcement covers, reuse [the approved artwork prompt](../../../docs/development/release-cover-artwork.md).
|
||||
|
||||
The tag workflow authors the public GitHub body with
|
||||
`node tools/release/extract-changelog-section.mjs --public "${VERSION}"`.
|
||||
Keep the full changelog, including internal notes, committed before tagging.
|
||||
|
||||
@@ -7,6 +7,8 @@ description: Use when preparing, cutting, tagging, publishing, or verifying an I
|
||||
|
||||
Full contract, asset table and rationale: `docs/architecture/release-pipeline.md`.
|
||||
|
||||
For imagegen announcement covers, reuse [the approved artwork prompt](../../../docs/development/release-cover-artwork.md).
|
||||
|
||||
The tag workflow authors the public GitHub body with
|
||||
`node tools/release/extract-changelog-section.mjs --public "${VERSION}"`.
|
||||
Keep the full changelog, including internal notes, committed before tagging.
|
||||
|
||||
@@ -13,6 +13,14 @@ name: Build and Make Electron App
|
||||
# cleanup-pr-draft.yml deletes the draft when the PR closes, so the stale
|
||||
# window is visible and bounded; refreshing drafts on skipped runs is not
|
||||
# worth a separate workflow.
|
||||
#
|
||||
# Master pushes are the nightly channel: the nightly-version job computes
|
||||
# one <patch>-nightly.<date>.<run number> version for the whole run
|
||||
# (tools/release/nightly-version.mjs), every build job writes it into
|
||||
# package.json so electron-updater treats the build as newer than the
|
||||
# released version, and the release job publishes the artifacts as a
|
||||
# prerelease of 4gray/iptvnator-nightly instead of the rolling test-master
|
||||
# draft. Contract: docs/architecture/release-pipeline.md ("Nightly channel").
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -42,6 +50,39 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# One version for the whole run. Computed here rather than in each build
|
||||
# job because the rule depends on whether the base tag exists on origin:
|
||||
# a tag pushed while the matrix runs would otherwise give one run two
|
||||
# different versions. Empty output means "not a nightly build".
|
||||
nightly-version:
|
||||
name: Resolve nightly version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator'
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve nightly version
|
||||
id: resolve
|
||||
shell: bash
|
||||
env:
|
||||
NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${NIGHTLY}" != "true" ]; then
|
||||
echo "version=" >> "${GITHUB_OUTPUT}"
|
||||
echo "Not a master push; no nightly version."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="$(node tools/release/nightly-version.mjs)"
|
||||
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Nightly version: ${VERSION}"
|
||||
|
||||
linux-embedded-mpv-runtime:
|
||||
name: Build pinned Linux Embedded MPV runtime
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -61,7 +102,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Resolve Linux runtime toolchain cache key
|
||||
id: linux-runtime-cache-key
|
||||
@@ -340,6 +381,7 @@ jobs:
|
||||
|
||||
build-cross-platform:
|
||||
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
|
||||
needs: nightly-version
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
@@ -379,7 +421,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
@@ -445,6 +487,23 @@ jobs:
|
||||
BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node tools/build/inject-build-commit.mjs
|
||||
|
||||
- name: Apply nightly version
|
||||
# Master merges feed the nightly update channel. The version
|
||||
# must be greater than the released one for electron-updater to
|
||||
# offer it, and it must be in package.json before the frontend
|
||||
# and backend builds and electron-builder read it. The value
|
||||
# comes from the nightly-version job so every job of this run
|
||||
# builds the same version. The script also sets the electron-
|
||||
# builder publish channel to "nightly", which names the updater
|
||||
# metadata nightly-mac.yml / nightly.yml / nightly-linux.yml.
|
||||
if: needs.nightly-version.outputs.version != ''
|
||||
# Windows runners default to PowerShell, where "${NIGHTLY_VERSION}"
|
||||
# expands to nothing and the script rejects the empty version.
|
||||
shell: bash
|
||||
env:
|
||||
NIGHTLY_VERSION: ${{ needs.nightly-version.outputs.version }}
|
||||
run: node tools/release/nightly-version.mjs --apply --version "${NIGHTLY_VERSION}"
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm nx build web --skip-nx-cache
|
||||
|
||||
@@ -1091,6 +1150,16 @@ jobs:
|
||||
electron-backend-e2e:packaged-frame-copy-smoke \
|
||||
--skip-nx-cache
|
||||
|
||||
- name: Upload packaged frame-copy smoke diagnostics
|
||||
if: always() && matrix.os == 'linux' && matrix.linux_profile == 'portable'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: packaged-frame-copy-smoke
|
||||
path: |
|
||||
dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke/
|
||||
dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/
|
||||
retention-days: 7
|
||||
|
||||
- name: Diagnose packaged x64 frame-copy hardware path
|
||||
if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
|
||||
continue-on-error: true
|
||||
@@ -1188,6 +1257,7 @@ jobs:
|
||||
dist/executables/**/*.dmg
|
||||
dist/executables/**/*.zip
|
||||
dist/executables/**/latest-mac.yml
|
||||
dist/executables/**/nightly-mac.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
@@ -1212,6 +1282,7 @@ jobs:
|
||||
dist/executables/*.AppImage
|
||||
dist/executables/*.snap
|
||||
dist/executables/**/latest-linux*.yml
|
||||
dist/executables/**/nightly-linux*.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
@@ -1234,12 +1305,15 @@ jobs:
|
||||
dist/executables/**/*.msi
|
||||
dist/executables/**/*.zip
|
||||
dist/executables/**/latest.yml
|
||||
dist/executables/**/nightly.yml
|
||||
dist/executables/**/*.blockmap
|
||||
retention-days: 7
|
||||
|
||||
build-linux:
|
||||
name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})
|
||||
needs: linux-embedded-mpv-runtime
|
||||
needs:
|
||||
- linux-embedded-mpv-runtime
|
||||
- nightly-version
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
@@ -1276,6 +1350,7 @@ jobs:
|
||||
create-release:
|
||||
name: Create Draft Release
|
||||
needs:
|
||||
- nightly-version
|
||||
- build-cross-platform
|
||||
- build-linux
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
@@ -1285,6 +1360,12 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
# Master pushes publish to the nightly repository (steps at the
|
||||
# end of this job) instead of the rolling draft.
|
||||
NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }}
|
||||
NIGHTLY_REPOSITORY: 4gray/iptvnator-nightly
|
||||
NIGHTLY_KEEP_RELEASES: '20'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -1306,10 +1387,20 @@ jobs:
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
|
||||
const candidates = [
|
||||
'artifacts/macos-x64-artifacts/latest-mac.yml',
|
||||
'artifacts/macos-arm64-artifacts/latest-mac.yml',
|
||||
].filter((filePath) => fs.existsSync(filePath));
|
||||
// electron-builder names the updater metadata after the
|
||||
// channel: latest-mac.yml for releases, nightly-mac.yml for
|
||||
// the prerelease versions master builds carry.
|
||||
const channelFile = ['latest-mac.yml', 'nightly-mac.yml'].find(
|
||||
(name) =>
|
||||
fs.existsSync(`artifacts/macos-x64-artifacts/${name}`) ||
|
||||
fs.existsSync(`artifacts/macos-arm64-artifacts/${name}`)
|
||||
);
|
||||
const candidates = channelFile
|
||||
? [
|
||||
`artifacts/macos-x64-artifacts/${channelFile}`,
|
||||
`artifacts/macos-arm64-artifacts/${channelFile}`,
|
||||
].filter((filePath) => fs.existsSync(filePath))
|
||||
: [];
|
||||
|
||||
if (candidates.length === 0) {
|
||||
console.log('No macOS update metadata found; skipping merge.');
|
||||
@@ -1407,8 +1498,8 @@ jobs:
|
||||
mergedEntries
|
||||
);
|
||||
|
||||
fs.writeFileSync('artifacts/latest-mac.yml', merged);
|
||||
console.log(`Merged ${candidates.length} macOS update metadata files.`);
|
||||
fs.writeFileSync(`artifacts/${channelFile}`, merged);
|
||||
console.log(`Merged ${candidates.length} macOS update metadata files into ${channelFile}.`);
|
||||
NODE
|
||||
|
||||
- name: Get version from package.json
|
||||
@@ -1422,6 +1513,7 @@ jobs:
|
||||
# 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
|
||||
if: env.NIGHTLY != 'true'
|
||||
id: release-meta
|
||||
shell: bash
|
||||
env:
|
||||
@@ -1499,7 +1591,7 @@ jobs:
|
||||
# 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'
|
||||
if: env.NIGHTLY != 'true' && (github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
|
||||
@@ -1522,7 +1614,7 @@ jobs:
|
||||
|
||||
- name: Create Draft Release
|
||||
id: draft-release
|
||||
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
|
||||
if: env.NIGHTLY != 'true' && (github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
draft: true
|
||||
@@ -1569,7 +1661,7 @@ jobs:
|
||||
# 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 != ''
|
||||
if: env.NIGHTLY != 'true' && steps.draft-release.outputs.id != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
|
||||
@@ -1612,3 +1704,196 @@ jobs:
|
||||
--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
|
||||
|
||||
# ── Nightly channel ──────────────────────────────────────────
|
||||
# Drafts are invisible to anyone without write access and to
|
||||
# electron-updater, so master builds are published as prereleases
|
||||
# of the nightly repository, which the desktop app's Nightly update
|
||||
# channel follows. The repository needs one commit on its default
|
||||
# branch (gh creates the release tag there) and a fine-grained PAT
|
||||
# with Contents: read/write on it, stored as NIGHTLY_RELEASE_TOKEN.
|
||||
# Without the token the build still succeeds and only warns.
|
||||
- name: Resolve nightly release metadata
|
||||
if: env.NIGHTLY == 'true'
|
||||
id: nightly-meta
|
||||
shell: bash
|
||||
env:
|
||||
NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
|
||||
VERSION: ${{ needs.nightly-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "::error::The nightly-version job produced no version for this master push."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
echo "tag=v${VERSION}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -z "${NIGHTLY_RELEASE_TOKEN}" ]; then
|
||||
echo "::warning::NIGHTLY_RELEASE_TOKEN is not configured; the nightly release for ${VERSION} is skipped."
|
||||
echo "publish=false" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "publish=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
# The notes list the master commits since the previous nightly.
|
||||
# That nightly's source commit is read back from the marker its
|
||||
# own notes carry, because the nightly repository has no copy of
|
||||
# the app history to compare against. The main-repository compare
|
||||
# uses GITHUB_TOKEN; only the nightly repository is read with the
|
||||
# PAT.
|
||||
- name: Compose nightly release notes
|
||||
if: steps.nightly-meta.outputs.publish == 'true'
|
||||
id: nightly-notes
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
|
||||
VERSION: ${{ steps.nightly-meta.outputs.version }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
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}"
|
||||
NOTES_FILE="${RUNNER_TEMP}/nightly-notes.md"
|
||||
|
||||
PREVIOUS_TAG="$(GH_TOKEN="${NIGHTLY_RELEASE_TOKEN}" gh release list \
|
||||
--repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 1 \
|
||||
--json tagName --jq '.[0].tagName // ""')"
|
||||
PREVIOUS_SHA=""
|
||||
if [ -n "${PREVIOUS_TAG}" ]; then
|
||||
PREVIOUS_SHA="$(GH_TOKEN="${NIGHTLY_RELEASE_TOKEN}" gh release view "${PREVIOUS_TAG}" \
|
||||
--repo "${NIGHTLY_REPOSITORY}" --json body --jq '.body // ""' |
|
||||
sed -n 's/.*<!-- iptvnator-commit: \([0-9a-f]\{40\}\) -->.*/\1/p' | head -n 1)"
|
||||
fi
|
||||
|
||||
COMMITS=""
|
||||
if [ -n "${PREVIOUS_SHA}" ] && [ "${PREVIOUS_SHA}" != "${HEAD_SHA}" ]; then
|
||||
# A rewritten history makes the compare fail; the notes
|
||||
# then just omit the list rather than failing the release.
|
||||
COMMITS="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PREVIOUS_SHA}...${HEAD_SHA}" \
|
||||
--jq '.commits[] | "- [`\(.sha[0:7])`](\(.html_url)) \(.commit.message | split("\n")[0])"' || true)"
|
||||
fi
|
||||
|
||||
{
|
||||
printf '🌙 Nightly build from `master` — commit [`%s`](%s/commit/%s) · [workflow run](%s)\n\n' \
|
||||
"${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}"
|
||||
printf 'Untested snapshot of master for the desktop app'"'"'s **Nightly** update channel (Settings → About → Update channel). Nightly builds may break, and their database changes are permanent: switching back to Stable keeps this build installed until the next stable release is newer. Back up your playlists first.\n\n'
|
||||
if [ -n "${COMMITS}" ]; then
|
||||
printf '## Changes since %s\n\n%s\n\n' "${PREVIOUS_TAG}" "${COMMITS}"
|
||||
else
|
||||
printf 'See the [commit history](%s/commits/master) for what changed.\n\n' "${REPO_URL}"
|
||||
fi
|
||||
printf '<!-- iptvnator-commit: %s -->\n' "${HEAD_SHA}"
|
||||
} > "${NOTES_FILE}"
|
||||
|
||||
echo "notes-file=${NOTES_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Created as a draft, assets uploaded, then published in one edit,
|
||||
# so electron-updater never sees a release whose channel file is
|
||||
# still missing. A published release is never deleted here: a
|
||||
# rerun after a successful publish is a no-op, and only a draft
|
||||
# left behind by a failed run is replaced. The release job is
|
||||
# serialized per ref but two master runs can still finish out of
|
||||
# order, so a nightly that is older than the newest published one
|
||||
# is dropped instead of becoming the feed's newest entry.
|
||||
- name: Publish nightly release
|
||||
if: steps.nightly-meta.outputs.publish == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
|
||||
TAG: ${{ steps.nightly-meta.outputs.tag }}
|
||||
VERSION: ${{ steps.nightly-meta.outputs.version }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
NOTES_FILE: ${{ steps.nightly-notes.outputs.notes-file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
NEWEST_PUBLISHED="$(gh release list --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 200 \
|
||||
--json tagName --jq '.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\."))' |
|
||||
sort -V | tail -n 1)"
|
||||
if [ -n "${NEWEST_PUBLISHED}" ] && [ "${NEWEST_PUBLISHED}" != "${TAG}" ] &&
|
||||
[ "$(printf '%s\n%s\n' "${NEWEST_PUBLISHED}" "${TAG}" | sort -V | tail -n 1)" != "${TAG}" ]; then
|
||||
echo "::notice::${NEWEST_PUBLISHED} is already published and newer than ${TAG}; not publishing this superseded build."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for required in \
|
||||
artifacts/nightly-mac.yml \
|
||||
artifacts/windows-artifacts/nightly.yml \
|
||||
artifacts/linux-portable-artifacts/nightly-linux.yml; do
|
||||
if [ ! -f "${required}" ]; then
|
||||
echo "::error::Missing updater metadata ${required}; the nightly channel would be unable to install this build."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
assets=(
|
||||
artifacts/macos-x64-artifacts/*-x64.dmg
|
||||
artifacts/macos-x64-artifacts/*-x64.zip
|
||||
artifacts/macos-x64-artifacts/*.blockmap
|
||||
artifacts/macos-arm64-artifacts/*-arm64.dmg
|
||||
artifacts/macos-arm64-artifacts/*-arm64.zip
|
||||
artifacts/macos-arm64-artifacts/*.blockmap
|
||||
artifacts/nightly-mac.yml
|
||||
artifacts/linux-system-artifacts/*.deb
|
||||
artifacts/linux-system-artifacts/*.rpm
|
||||
artifacts/linux-system-artifacts/*.pacman
|
||||
artifacts/linux-system-artifacts/*.pkg.tar.*
|
||||
artifacts/linux-portable-artifacts/*.AppImage
|
||||
artifacts/linux-portable-artifacts/*.snap
|
||||
artifacts/linux-portable-artifacts/nightly-linux*.yml
|
||||
artifacts/linux-portable-artifacts/*.blockmap
|
||||
artifacts/linux-flatpak-artifacts/*.flatpak
|
||||
artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz
|
||||
artifacts/windows-artifacts/*-setup.exe
|
||||
artifacts/windows-artifacts/*.msi
|
||||
artifacts/windows-artifacts/*.zip
|
||||
artifacts/windows-artifacts/nightly.yml
|
||||
artifacts/windows-artifacts/*.blockmap
|
||||
)
|
||||
|
||||
EXISTING="$(gh api "repos/${NIGHTLY_REPOSITORY}/releases?per_page=100" --paginate |
|
||||
jq -c --arg tag "${TAG}" 'map(select(.tag_name == $tag)) | first // empty')"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
if [ "$(jq -r '.draft' <<< "${EXISTING}")" != "true" ]; then
|
||||
echo "::notice::${TAG} is already published in ${NIGHTLY_REPOSITORY}; nothing to do for this re-run."
|
||||
exit 0
|
||||
fi
|
||||
DRAFT_ID="$(jq -r '.id' <<< "${EXISTING}")"
|
||||
echo "Removing the draft ${TAG} (id ${DRAFT_ID}) a failed run left behind."
|
||||
gh api -X DELETE "repos/${NIGHTLY_REPOSITORY}/releases/${DRAFT_ID}"
|
||||
fi
|
||||
|
||||
gh release create "${TAG}" "${assets[@]}" \
|
||||
--repo "${NIGHTLY_REPOSITORY}" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--title "Nightly ${VERSION} (${HEAD_SHA:0:7})" \
|
||||
--notes-file "${NOTES_FILE}"
|
||||
gh release edit "${TAG}" --repo "${NIGHTLY_REPOSITORY}" --draft=false --prerelease
|
||||
|
||||
echo "Published ${TAG} to ${NIGHTLY_REPOSITORY} with ${#assets[@]} assets."
|
||||
|
||||
- name: Prune old nightly releases
|
||||
if: steps.nightly-meta.outputs.publish == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh release list --repo "${NIGHTLY_REPOSITORY}" --exclude-drafts --limit 200 \
|
||||
--json tagName --jq '.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+-nightly\\."))' |
|
||||
sort -V -r | tail -n "+$((NIGHTLY_KEEP_RELEASES + 1))" |
|
||||
while read -r tag; do
|
||||
[ -n "${tag}" ] || continue
|
||||
echo "Deleting nightly ${tag} (keeping the newest ${NIGHTLY_KEEP_RELEASES})."
|
||||
gh release delete "${tag}" --repo "${NIGHTLY_REPOSITORY}" --cleanup-tag --yes
|
||||
done
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -141,12 +141,15 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate agent guidance
|
||||
run: pnpm run agents:validate
|
||||
|
||||
- name: Validate Nx dependency version policy
|
||||
run: pnpm run deps:nx:validate
|
||||
|
||||
|
||||
@@ -3,12 +3,20 @@ name: Cleanup PR Draft Release
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
# The event above is the fast path, not a guarantee: GitHub does not run a
|
||||
# `pull_request: closed` workflow when the head ref is already gone at
|
||||
# event time, which is exactly what Dependabot does when it supersedes one
|
||||
# of its own PRs (closes it and deletes the branch in a single operation).
|
||||
# Those drafts — and any the event path missed for other reasons — are
|
||||
# collected by the scheduled sweep below.
|
||||
schedule:
|
||||
- cron: '17 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# contents: write — delete the draft release; actions: write — cancel the
|
||||
# closed PR's still-running build workflow before deleting.
|
||||
# contents: write — delete the draft release. The `actions: write` needed to
|
||||
# cancel a closed PR's still-running build is scoped to the event job.
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
delete-draft:
|
||||
@@ -16,8 +24,11 @@ jobs:
|
||||
# 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
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
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
|
||||
@@ -90,3 +101,94 @@ jobs:
|
||||
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/{}"
|
||||
|
||||
sweep-drafts:
|
||||
name: Sweep orphaned PR draft releases
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
# Two sweeps must not race each other into a double delete; a manual
|
||||
# dispatch during the nightly cron would otherwise produce a spurious
|
||||
# failure on an already-deleted draft.
|
||||
concurrency:
|
||||
group: cleanup-pr-draft-sweep
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Unlike the event job this one does not cancel in-progress builds:
|
||||
# the build's draft steps are themselves gated on the live PR state
|
||||
# being `open` ("Check PR is still open" in build-and-make.yaml), so
|
||||
# a run that outlives the close cannot recreate what was swept. A
|
||||
# build that passed that check just before the PR closed is caught
|
||||
# by the next sweep.
|
||||
#
|
||||
# Draft releases have no real git tag, so they are invisible to
|
||||
# `gh release view <tag>` and to the tags API. Enumerate releases
|
||||
# and match on the stored tag_name, exactly as the event job does.
|
||||
# The `test-<branch>` drafts and every other release are left
|
||||
# alone by the ^test-pr-<n>$ shape.
|
||||
- name: Delete drafts whose PR is closed
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Assigned rather than piped: a failed or partially paginated
|
||||
# listing must fail the job instead of silently sweeping a
|
||||
# short list.
|
||||
drafts="$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate \
|
||||
--jq '.[] | select(.draft and (.tag_name | test("^test-pr-[0-9]+$"))) | "\(.id) \(.tag_name)"'
|
||||
)"
|
||||
|
||||
if [ -z "${drafts}" ]; then
|
||||
echo "No test-pr-<n> drafts found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
failed=0
|
||||
|
||||
while IFS=' ' read -r release_id tag; do
|
||||
pr_number="${tag#test-pr-}"
|
||||
|
||||
# Fail closed: a lookup error (404, rate limit, outage)
|
||||
# leaves `state` empty and the draft untouched. Only a
|
||||
# PR GitHub currently reports as closed loses its draft,
|
||||
# so a reopened PR and an open PR mid-build keep theirs.
|
||||
# gh's own stderr is left visible on purpose — a draft
|
||||
# kept as `unknown` should say why in the job log.
|
||||
state="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.state' || true)"
|
||||
if [ "${state}" != "closed" ]; then
|
||||
echo "Keeping ${tag}: PR #${pr_number} is ${state:-unknown}."
|
||||
continue
|
||||
fi
|
||||
|
||||
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}" >/dev/null; then
|
||||
echo "Deleted ${tag} (release ${release_id}) for closed PR #${pr_number}."
|
||||
continue
|
||||
fi
|
||||
|
||||
# The delete failed. Only a release GitHub confirms is
|
||||
# gone (404) excuses that — the event job racing us to
|
||||
# the same draft. `gh api` exits 1 for every failure
|
||||
# alike, so a rate limit or outage hitting both calls
|
||||
# would otherwise read as "already deleted" and leave a
|
||||
# green sweep behind an undeleted draft. Read the status
|
||||
# line instead: `-i` prints it even on an error status,
|
||||
# and a request that never got a response leaves it
|
||||
# empty, which is not 404 and so stays a failure.
|
||||
recheck_status="$(
|
||||
gh api -i "repos/${GITHUB_REPOSITORY}/releases/${release_id}" 2>/dev/null |
|
||||
sed -n '1s#^HTTP/[0-9.]* \([0-9]\{3\}\).*#\1#p' || true
|
||||
)"
|
||||
|
||||
if [ "${recheck_status}" = "404" ]; then
|
||||
echo "Draft ${tag} (release ${release_id}) was already gone."
|
||||
else
|
||||
echo "::error::Failed to delete draft ${tag} (release ${release_id}); re-check returned ${recheck_status:-no HTTP status}."
|
||||
failed=1
|
||||
fi
|
||||
done <<< "${drafts}"
|
||||
|
||||
exit "${failed}"
|
||||
@@ -6,6 +6,7 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
@@ -68,6 +68,9 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: pnpm nx build electron-backend
|
||||
|
||||
- name: Verify Electron process cleanup
|
||||
run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
@@ -113,7 +116,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Refresh pin when it approaches upstream retention
|
||||
id: refresh
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent guidance reorganization — issue #1643
|
||||
|
||||
Approved implementation plan, 2026-09-20.
|
||||
|
||||
## Outcome
|
||||
|
||||
One source of common instructions: AGENTS.md (at most 200 lines / 16 KiB).
|
||||
CLAUDE.md imports @AGENTS.md and contains only Claude-specific guidance
|
||||
(at most 30 lines / 2 KiB). Do not increase Codex loading limits. No runtime
|
||||
or public API changes.
|
||||
|
||||
## Knowledge preservation
|
||||
|
||||
Inventory both original files at the starting commit in
|
||||
`docs/maintenance/agent-guidance-migration.md`. Record source section and line
|
||||
ranges, destination document and heading, and whether each contract was moved,
|
||||
merged with an existing equivalent, or corrected with evidence. Split long
|
||||
player sections into individual contracts. Preserve exceptions, commands,
|
||||
rationale and platform constraints. Do not create a required monolithic archive.
|
||||
|
||||
## Destinations
|
||||
|
||||
Use existing authoritative docs first: Nx boundaries for structure/dependencies;
|
||||
validation-map for tests/lint; release-pipeline and release skills for releases;
|
||||
sqlite-db-worker and the database README for IPC/migrations; m3u-playlist-module
|
||||
for M3U/XMLTV/startup/source health; Xtream/Stalker compatibility docs for portals;
|
||||
player-controls-contract for web controls/radio/sleep; embedded-mpv-native for
|
||||
native runtime/packaging; UI guidelines, detail navigation and remote control for
|
||||
navigation; PWA/host connectivity/security docs for networking; existing download,
|
||||
TMDB, multi-source, workspace and backup docs for their domains; website README
|
||||
for website policy.
|
||||
|
||||
Create docs/development/agent-workflow.md for documentation/skill maintenance and
|
||||
Angular conventions, and docs/development/electron-debugging.md for CDP/tracing.
|
||||
Add a developer navigation link in README.md.
|
||||
|
||||
## Root guidance and navigation
|
||||
|
||||
Retain project purpose, essential commands, .nvmrc/frozen install/Nx bootstrap,
|
||||
scoped imports and boundaries, migration safety, credential redaction, regression
|
||||
coverage, release-note/doc requirements, protected Markdown formatting and plan
|
||||
storage. Preserve the Nx-managed block/markers, conditional on available tools.
|
||||
Replace mandatory root-file updates with updates to each subsystem's canonical
|
||||
doc. Root instructions hold only universal rules and a compact topic routing table.
|
||||
Create docs/maintenance/agent-context-map.md with topics, code paths, docs and
|
||||
skills. Read affected contracts only; cross-domain work reads each relevant one.
|
||||
Update existing skills rather than proliferating copies; preserve byte-identical
|
||||
release mirrors. No mass nested instructions in this change.
|
||||
|
||||
## Tooling
|
||||
|
||||
Extend repository-skills (no new Nx project) with agents:validate and node:test
|
||||
coverage. Check UTF-8 bytes/line budgets, one standalone @AGENTS.md import in
|
||||
CLAUDE.md and no other root imports, local navigation/map/migration links and
|
||||
anchors, and literal repository paths without treating globs/commands as paths.
|
||||
Add an unconditional CI validation step and correct Nx test inputs/lint commands.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Tests cover exact/over budgets, UTF-8, LF/CRLF, missing/duplicate/extra imports,
|
||||
missing local files and anchors. Run frozen install, Nx discovery, repository-skills
|
||||
test/lint, agents:validate, skills:validate, release:notes:validate, git diff --check
|
||||
and workflow validation. Audit every source block to a destination, with no
|
||||
unresolved or lost unique contract. Walk navigation for XMLTV, Xtream, MPV,
|
||||
migrations and releases. App unit/E2E is unnecessary (no runtime changes); no
|
||||
release note for docs/tooling validation. Do not run whole-file Prettier on docs,
|
||||
AGENTS.md or CLAUDE.md.
|
||||
+141
@@ -11,6 +11,147 @@ this marker — see `.changes/README.md`.
|
||||
|
||||
<!-- next-release -->
|
||||
|
||||
# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-21)
|
||||
|
||||
|
||||

|
||||
|
||||
## Highlights
|
||||
|
||||
### Programme guide, rebuilt
|
||||
|
||||
The M3U programme guide now follows your playlist's own channels and order, with groups and favorites one click away. Keep the player above the grid while you browse, click a channel to switch, or double-click to switch and close. Open the guide with **G**, hide channels without EPG and choose compact or comfortable rows. ([#1560](https://github.com/4gray/iptvnator/pull/1560), closes [#171](https://github.com/4gray/iptvnator/issues/171))
|
||||
|
||||
### Browse channels and episodes without leaving fullscreen
|
||||
|
||||
Press **C** or use the left edge of the video to open a searchable channel list. Series get season tabs and an episode list with artwork and watch progress too. With the default shared controls, switching channels, episodes or alternative movie sources keeps the player fullscreen. ([#1519](https://github.com/4gray/iptvnator/pull/1519), [#1620](https://github.com/4gray/iptvnator/pull/1620), [#1509](https://github.com/4gray/iptvnator/pull/1509), closes [#48](https://github.com/4gray/iptvnator/issues/48))
|
||||
|
||||
### See what is playing while it plays
|
||||
|
||||
The stream-info overlay shows resolution, frame rates, bitrates, audio details, buffer and dropped frames when the player can report them. Available in the built-in web players and experimental frame-copy Embedded MPV; unavailable values stay hidden. ([#1578](https://github.com/4gray/iptvnator/pull/1578))
|
||||
|
||||
---
|
||||
|
||||
**27 features · 54 fixes · 1 performance improvement** — including local XMLTV files, Xtream catch-up downloads, automatic reconnects for embedded MPV, season artwork and recovery for sources missing after an older upgrade.
|
||||
|
||||
Read the 📝 [release story on the blog](https://4gray.github.io/iptvnator/blog/v0-24-release-notes/), the [full CHANGELOG](https://github.com/4gray/iptvnator/blob/v0.24.0/CHANGELOG.md), or the [changes since v0.23.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0).
|
||||
|
||||
Before updating, back up your playlists and important data. Desktop startup applies database updates and clears outdated metadata matches; allow the preparation screen to finish and avoid downgrading an upgraded profile.
|
||||
|
||||
## Thanks
|
||||
|
||||
Thank you to [@Bpl5966](https://github.com/Bpl5966) for MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)). A warm thank-you to everyone who reported issues, tested builds, helped in the [Telegram community](https://t.me/iptvnator), or supported development. 💙
|
||||
|
||||
If IPTVnator is useful to you, you can help keep it moving through [GitHub Sponsors](https://github.com/sponsors/4gray) or [Ko-fi](https://ko-fi.com/4gray).
|
||||
|
||||
<details>
|
||||
<summary><b>All features, fixes and performance improvements</b></summary>
|
||||
|
||||
### Features
|
||||
|
||||
- **collections** — A live channel you watch from Favorites or Recently viewed now says which playlist it belongs to: a chip with the playlist name sits in the programme panel next to the channel, and clicking it (or "Open in <playlist>" in the channel's right-click menu) jumps to that channel inside its own playlist. Works for Xtream and M3U sources. ([#1634](https://github.com/4gray/iptvnator/pull/1634))
|
||||
- **collections** — "Open in <playlist>" for live channels in Favorites and Recently viewed now works for Stalker portals too: the chip in the programme panel and the channel's right-click menu jump to that channel inside its portal's Live TV, with its category selected and the channel playing. Radio stations keep the existing behaviour. ([#1639](https://github.com/4gray/iptvnator/pull/1639))
|
||||
- **dashboard** — The dashboard no longer labels every title with "Xtream · Series" or "Stalker · Movie": the hero shows only the source name, and Continue Watching cards show the episode you are on plus the minutes left. Stalker shows filed under Movies now get that S·E badge, the progress bar, and a working "Resume episode" that opens the show at the saved episode. ([#1646](https://github.com/4gray/iptvnator/pull/1646))
|
||||
- **dashboard** — On the desktop app, favourite and recently watched Xtream and Stalker channels on the dashboard now show what is on air, with the programme's time and progress. Each card asks its portal only once it scrolls into view and fills in on its own, so a slow portal never holds up the page or the other cards. ([#1638](https://github.com/4gray/iptvnator/pull/1638))
|
||||
- **downloads** — Download completed Xtream catch-up programmes as TS files from Live TV programme details. Find them in Downloads with the channel and broadcast date and play them offline. Interrupted archive downloads restart from the beginning. ([#1572](https://github.com/4gray/iptvnator/pull/1572))
|
||||
- **embedded-mpv** — The embedded MPV player now reloads a live stream that drops mid-playback on its own, with increasing delays and an "attempt N of 6" line instead of a dead error screen; a new Settings > Playback toggle turns this off. The same section gains an advanced field for extra libmpv options (one key=value per line) that applies on every engine, with a short network timeout on by default. ([#1515](https://github.com/4gray/iptvnator/pull/1515))
|
||||
- **epg** — Copy a catch-up programme’s URL from its EPG details in Xtream and supported M3U sources, including Favorites and Recent, without interrupting playback. The link can be used in an external player or download tool. ([#1569](https://github.com/4gray/iptvnator/pull/1569))
|
||||
- **epg** — EPG settings now include a display-only time offset, making it easy to correct provider feeds with missing or incorrect timezone information without re-importing the guide. ([#1489](https://github.com/4gray/iptvnator/pull/1489), closes [#50](https://github.com/4gray/iptvnator/issues/50))
|
||||
- **epg** — EPG sources no longer have to be URLs: point Settings → EPG or a playlist's EPG list at an XMLTV file on your computer, by absolute path, `file://` link, or the new folder button. Plain `.xml` and gzip `.xml.gz` both work. A typed path is confirmed once in a native dialog before it is read; the settings page spells out the accepted formats with examples. ([#1600](https://github.com/4gray/iptvnator/pull/1600))
|
||||
- **epg** — The programme guide now shows your playlist's own channels in their order, with the current group or favorites one click away. Click a channel to switch playback while the player stays on screen above the grid; double-click switches and closes. Open it from the new Guide button, the header, the command palette, or the G key. Hide channels without EPG and pick comfortable or compact rows. ([#1560](https://github.com/4gray/iptvnator/pull/1560), closes [#171](https://github.com/4gray/iptvnator/issues/171))
|
||||
- **epg** — The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up. ([#1557](https://github.com/4gray/iptvnator/pull/1557))
|
||||
- **packaging** — AppImage builds now include an update source for AppManager, allowing it to discover new GitHub releases and download full application updates without manually configuring the repository. ([#1559](https://github.com/4gray/iptvnator/pull/1559))
|
||||
- **playback** — Playback errors now explain confirmed access and DRM license failures, with stream codecs, DRM systems and failure stages in the details. Copy diagnostics creates a support report without stream URLs or credentials. No extra requests are made to your provider. ([#1574](https://github.com/4gray/iptvnator/pull/1574))
|
||||
- **playback** — Rest the mouse on the left edge (tap it on touch) or press C while a live channel plays fullscreen: the channel list (M3U all/groups/favorites/recent, Xtream, Stalker, global favorites) slides over the video with search, so you can zap without leaving fullscreen. Nothing covers the video while it is closed. M3U playlists also zap with PageUp/PageDown. ([#1519](https://github.com/4gray/iptvnator/pull/1519), closes [#48](https://github.com/4gray/iptvnator/issues/48))
|
||||
- **playback** — Series playback now has the slide-in list in fullscreen too: rest the mouse on the left edge, click it, or press C to see the season tabs and episodes with stills, runtimes, descriptions and watch progress, and jump to another episode without leaving fullscreen. Works for Xtream and Stalker series with any built-in player. ([#1620](https://github.com/4gray/iptvnator/pull/1620))
|
||||
- **playback** — The player overlay now shows stream information: resolution, playback and source frame rates, stream and codec bitrates, audio details, buffer and dropped frames. Available in built-in web players and experimental frame-copy Embedded MPV. Unknown or unavailable values stay hidden; measured frame rates reflect dropped frames and stalls. ([#1578](https://github.com/4gray/iptvnator/pull/1578))
|
||||
- **playlist** — Desktop users can review inactive sources and delete selected ones together. The dialog checks the whole library, preselects confirmed expired or disabled accounts, and lets you keep individual sources. Busy sources are skipped, and deletion results are reported individually. ([#1596](https://github.com/4gray/iptvnator/pull/1596))
|
||||
- **playlist** — Desktop playlist lists now show availability for Stalker portals and M3U links alongside Xtream accounts. Checks share cached results, run in the background, and explain when a source could not be verified. ([#1592](https://github.com/4gray/iptvnator/pull/1592))
|
||||
- **portal** — Movies can now be marked as watched (or unwatched) by hand from their detail page on Xtream and Stalker sources, including the Favorites and Recently Viewed views — the same way seasons and episodes already could. The catalog shows the green check right away, and a watched movie offers Play instead of a Resume near the end. ([#1605](https://github.com/4gray/iptvnator/pull/1605))
|
||||
- **portal** — Series with more than six seasons now show a small season poster in the season dropdown — on each row that has one and on the closed selector — so a long list of "Season N" entries is easier to scan. The fullscreen episode panel's season strip also gets its own "N episodes" translation instead of borrowing the download manager's. ([#1633](https://github.com/4gray/iptvnator/pull/1633))
|
||||
- **portal** — Series pages now show each season's own poster next to the season tabs, and the fullscreen episode list shows it above its tabs too. The picture comes from TMDB when metadata enrichment is on, otherwise from your provider's season art; one-season shows keep the page as it was. ([#1628](https://github.com/4gray/iptvnator/pull/1628))
|
||||
- **portals** — Desktop settings now let you disable the temporary pause after repeated Xtream or Stalker connection failures, without restarting. Account info explains when requests are paused and offers an immediate retry. ([#1536](https://github.com/4gray/iptvnator/pull/1536))
|
||||
- **portals** — Live TV panels now fold in steps: hide only the categories rail and keep the channel list, or hide both for a player-only view. With categories hidden, the list header becomes a category dropdown, so switching categories stays one click away, and hiding both panels then bringing them back returns to the level you had before. ([#1556](https://github.com/4gray/iptvnator/pull/1556))
|
||||
- **portals** — Movie and series grids can now drop the title row: turn off "Show titles under covers" in Settings > General and the catalog, favorites and recent grids become a wall of covers that shows more rows per screen. The title slides in on the cover when you point at it or focus it with the keyboard, and stays on for items whose cover is missing. Live channels and search results keep their labels. ([#1604](https://github.com/4gray/iptvnator/pull/1604))
|
||||
- **shell** — Settings → General has a new "Window on startup" option: open the desktop app at its last size, maximized, or fullscreen — handy on a TV or HTPC. Starting with `--fullscreen` forces a single fullscreen launch without changing the setting, and F11 now toggles fullscreen anywhere in the app. ([#1514](https://github.com/4gray/iptvnator/pull/1514), closes [#1455](https://github.com/4gray/iptvnator/issues/1455))
|
||||
- **shell** — Zoom the app from the keyboard on every platform: Ctrl (Cmd on macOS) with +/- steps the size in about 10 % increments, including on the numpad, and Ctrl/Cmd+0 returns to the default. Windows and Linux gain these shortcuts, which previously existed only in the macOS menu. ([#1623](https://github.com/4gray/iptvnator/pull/1623), closes [#1109](https://github.com/4gray/iptvnator/issues/1109))
|
||||
- **updater** — Settings → About now has an Update channel switch. Nightly follows every merge into master: the desktop updater offers each new build automatically, and About shows the build's commit for bug reports. Stable stays the default; a nightly build remains installed until the next stable release is newer. ([#1608](https://github.com/4gray/iptvnator/pull/1608))
|
||||
|
||||
### Fixes
|
||||
|
||||
- **collections** — Switching Favorites or Recently Viewed between "This playlist" and "All playlists" now shows a thin progress bar under the header and dims the list while the new items load, instead of leaving the old list on screen with no sign that anything happened. The toggle reflects your choice at once, a playing channel keeps playing, and quick switches show no indicator at all. ([#1636](https://github.com/4gray/iptvnator/pull/1636))
|
||||
- **dashboard** — The dashboard's "Now on air on favorite channels" and "Recently watched Live TV" rails now show the current programme for channels whose guide lives in an XMLTV another playlist imported, not only in the global EPG sources from Settings — the same lookup the "See all" pages already used. ([#1637](https://github.com/4gray/iptvnator/pull/1637))
|
||||
- **database** — Updating directly from older versions no longer blocks playlist loading with a missing database column error. Existing sources, favorites, and playback history are preserved. ([#1582](https://github.com/4gray/iptvnator/pull/1582), closes [#1580](https://github.com/4gray/iptvnator/issues/1580))
|
||||
- **embedded-mpv** — When a stream refuses the connection, the embedded MPV player (frame-copy engine) no longer hands the URL to yt-dlp before giving up: the failure shows up right away and its error no longer reads "youtube-dl failed: unexpected error occurred", matching the native-view engines and the external MPV player. ([#1526](https://github.com/4gray/iptvnator/pull/1526))
|
||||
- **epg** — EPG guides now load when a server adds gzip compression to an already compressed XMLTV file. Sources using a single gzip layer continue to work. ([#1589](https://github.com/4gray/iptvnator/pull/1589), closes [#1586](https://github.com/4gray/iptvnator/issues/1586))
|
||||
- **epg** — The EPG import panel no longer shows a horizontal scrollbar while sources download. Source names now read as host/file instead of running the two together, channel and programme counts use your locale's digit grouping, and the minimize button and summary are translated. ([#1599](https://github.com/4gray/iptvnator/pull/1599))
|
||||
- **epg** — Removing and saving an EPG source now clears its cached programmes, including data left by previously removed sources on restart. Other configured sources and playlist guides are preserved, and Live TV stops showing programmes from the removed source. ([#1548](https://github.com/4gray/iptvnator/pull/1548))
|
||||
- **host-health** — Portal recovery now waits for an active probe to finish before sending another request, even when redirects or a slow response take longer than 45 seconds. Completed and cancelled requests release the probe slot reliably. ([#1547](https://github.com/4gray/iptvnator/pull/1547), closes [#1439](https://github.com/4gray/iptvnator/issues/1439))
|
||||
- **live-tv** — A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else. ([#1555](https://github.com/4gray/iptvnator/pull/1555), closes [#1458](https://github.com/4gray/iptvnator/issues/1458))
|
||||
- **m3u** — ClearKey channels with ordinary Base64 keys now play when imported or refreshed, including JSON playlist exports that use + and / characters. Refresh an existing playlist once to apply the fix. ([#1575](https://github.com/4gray/iptvnator/pull/1575))
|
||||
- **m3u** — DASH channels, including ClearKey-protected streams, now play from Recently Viewed and Favorites using the compatible built-in player. Existing imports keep their DRM keys without needing to re-import the playlist. ([#1597](https://github.com/4gray/iptvnator/pull/1597), closes [#1590](https://github.com/4gray/iptvnator/issues/1590))
|
||||
- **m3u** — M3U URL imports now accept a custom User-Agent for providers that require it before downloading the playlist. The value is saved and reused for playlist refreshes in the desktop app and self-hosted web app. ([#1535](https://github.com/4gray/iptvnator/pull/1535), closes [#465](https://github.com/4gray/iptvnator/issues/465), closes [#1120](https://github.com/4gray/iptvnator/issues/1120))
|
||||
- **m3u** — Movies and episodes recognized as video files in M3U playlists now offer playback time and seeking in built-in players, even when TMDB or movie details are disabled. Seeking remains dependent on the source's support. ([#1594](https://github.com/4gray/iptvnator/pull/1594))
|
||||
- **migration** — Upgrades from older desktop versions retain all sources from the legacy profile. Already-upgraded users can choose to recover missing sources without replacing current sources or settings. Migration keeps the original data and retries safely after a failed write. ([#1550](https://github.com/4gray/iptvnator/pull/1550), closes [#1504](https://github.com/4gray/iptvnator/issues/1504))
|
||||
- **migration** — Startup now shows a theme-aware preparation screen while loading sources and checking a large programme guide. Temporary source-read failures retry automatically; if loading still fails, you can retry without restarting the app. Recovery also completes any previously failed programme-guide cleanup before opening the library. ([#1568](https://github.com/4gray/iptvnator/pull/1568))
|
||||
- **playback** — The built-in player controls now hide on their own after you click a button in the bar with the mouse, such as fullscreen or mute. Previously the clicked button kept the bar pinned on screen until you clicked the video, which also paused it. Applies to the shared controls in the web players and the Embedded MPV frame-copy engine. ([#1512](https://github.com/4gray/iptvnator/pull/1512))
|
||||
- **playback** — Arrow keys and the ±10 s buttons in the Embedded MPV player now move by their full step every time. Pressing an arrow repeatedly, or holding it, used to advance only about a second per press because each step was computed from a stale position; steps are now relative seeks executed by mpv itself, so rapid presses add up. ([#1518](https://github.com/4gray/iptvnator/pull/1518))
|
||||
- **playback** — The fullscreen channel list is easier to find and calmer to use: moving the mouse shows a small tab on the left edge, a click on that edge opens the list at once, hover still opens it after a short rest, and the list now stays open for a full second after the mouse leaves. Opened with `C`, it no longer closes while the mouse only wanders over the video. ([#1619](https://github.com/4gray/iptvnator/pull/1619))
|
||||
- **playback** — The fullscreen channel panel stays open under the pointer when its opening animation is delayed, instead of closing before the list appears. ([#1591](https://github.com/4gray/iptvnator/pull/1591))
|
||||
- **playback** — With the default shared player controls, the built-in player now stays in fullscreen when you switch to another episode, when the next episode starts automatically, and when a live channel or an alternative movie source is switched — previously every switch dropped back to the page. The legacy vendor-controls opt-out keeps its previous behavior. ([#1509](https://github.com/4gray/iptvnator/pull/1509))
|
||||
- **playback** — Switching channels or leaving playback now closes the old picture-in-picture window in HTML5, Video.js, and ArtPlayer even when shared player controls are disabled, preventing frozen or outdated video from remaining on top. This includes Safari’s legacy picture-in-picture mode. ([#1538](https://github.com/4gray/iptvnator/pull/1538))
|
||||
- **playback** — Embedded MPV controls and programme-guide panels now follow the visual theme and stay readable in light and dark mode. Video overlays retain their contrasting dark backgrounds. ([#1541](https://github.com/4gray/iptvnator/pull/1541), closes [#1530](https://github.com/4gray/iptvnator/issues/1530))
|
||||
- **playback** — The built-in HTML5 player now plays `.mkv`, `.webm`, `.avi`, `.mov` and other non-HLS video files directly instead of handing them to the HLS engine, which failed with a "network or provider loading error" on many Xtream episodes and movies. ArtPlayer and the HTML5 player now choose their engine by the same rule. ([#1510](https://github.com/4gray/iptvnator/pull/1510))
|
||||
- **playback** — Keyboard shortcuts work again right after you click a button in the built-in player controls. Previously the clicked button kept the keyboard: after clicking fullscreen, Space left fullscreen instead of pausing, and the seek, volume and mute keys did nothing until you clicked the video. Applies to the shared controls in the web players and the Embedded MPV frame-copy engine. ([#1516](https://github.com/4gray/iptvnator/pull/1516))
|
||||
- **playback** — With IPTVnator's shared controls turned off, the Video.js player's keyboard shortcuts now work again right after you click a button in its control bar. Previously the clicked button kept the keyboard: after clicking fullscreen, Space left fullscreen instead of pausing, and the seek, volume and mute keys did nothing until you clicked the video. ([#1523](https://github.com/4gray/iptvnator/pull/1523))
|
||||
- **playback** — VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled. ([#1533](https://github.com/4gray/iptvnator/pull/1533), closes [#871](https://github.com/4gray/iptvnator/issues/871))
|
||||
- **playlists** — Opening an M3U playlist immediately after refreshing now waits for its pending save, so the channel list does not revert to the previous catalog. ([#1591](https://github.com/4gray/iptvnator/pull/1591))
|
||||
- **portals** — Xtream and Stalker portals no longer enter a connection cooldown merely because several parallel requests fail in the same millisecond. This fixes false unavailability in both the desktop app and the self-hosted web version. ([#1537](https://github.com/4gray/iptvnator/pull/1537), closes [#1438](https://github.com/4gray/iptvnator/issues/1438))
|
||||
- **portals** — Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior. ([#1554](https://github.com/4gray/iptvnator/pull/1554), closes [#1520](https://github.com/4gray/iptvnator/issues/1520))
|
||||
- **portals** — A slow Xtream or Stalker panel is no longer mistaken for a dead one: a request that reached the panel and then timed out no longer trips the 30-second "portal is not responding" pause, which only fires when the panel never accepts the connection. The desktop app also gives IPv4 fallback the same 2.5-second budget as the self-hosted backend, so dual-stack panels behind VPNs stop failing in bursts. ([#1621](https://github.com/4gray/iptvnator/pull/1621))
|
||||
- **search** — Search now returns the same results whether you type upper or lower case Turkish characters. Titles with the dotted capital I (for example "İnşaat") are found by typing a plain lower case i, so "inş" and "İnş" match the same channels and movies — in global search, in each portal's search, in the command palette, and in the channel, category, favorites and download list filters. ([#1640](https://github.com/4gray/iptvnator/pull/1640), closes [#609](https://github.com/4gray/iptvnator/issues/609))
|
||||
- **settings** — The update status in Settings → About now names the channel it describes, and after picking another channel the check button becomes "Save and check": one click saves the choice and reports what that channel offers. Previously "Check again" silently re-checked the still-saved channel, so a fresh nightly could look like "no update". ([#1631](https://github.com/4gray/iptvnator/pull/1631))
|
||||
- **shell** — Reloading the desktop app while on any page no longer breaks it: View › Reload on macOS used to leave a blank, dead window until restart, and the reload offered by the settings unsaved-changes dialog did nothing. Both now reload the app back onto the page you were on. ([#1622](https://github.com/4gray/iptvnator/pull/1622))
|
||||
- **shell** — The app now remembers your zoom level (Cmd/Ctrl and +/-): it holds when you switch between sections or resize the window, and comes back after a restart, so a larger font size no longer resets to the default. ([#1617](https://github.com/4gray/iptvnator/pull/1617), closes [#1109](https://github.com/4gray/iptvnator/issues/1109))
|
||||
- **stalker** — Stalker items whose portal sends an empty `id` next to a valid one are no longer lost: such a channel or movie can be favorited, played, resumed and found again, instead of being stored without an identity and quietly refusing to open. ([#1639](https://github.com/4gray/iptvnator/pull/1639))
|
||||
- **stalker** — Stalker Live TV search stays within the selected category in the sidebar and fullscreen panel, including channels beyond the first page. All Items searches the whole catalog, and the two search fields work independently. ([#1552](https://github.com/4gray/iptvnator/pull/1552), closes [#1543](https://github.com/4gray/iptvnator/issues/1543))
|
||||
- **stalker** — In Stalker portals, switching the Live TV or radio category in the sidebar no longer stops the channel that is playing. The category only changes which channels are listed, matching how Xtream portals and M3U playlists already behave. ([#1517](https://github.com/4gray/iptvnator/pull/1517))
|
||||
- **stalker** — Stalker series with season markers such as “s02” or “2 сезон” now show the correct season in tabs and episode labels while preserving watch progress. TMDB updates no longer reset loaded episodes, and delayed portal responses cannot mix episodes from different series. ([#1545](https://github.com/4gray/iptvnator/pull/1545))
|
||||
- **stalker** — Seeking in Stalker movies and series now reaches the selected position instead of jumping forward or skipping to the next episode after resuming playback. ([#1544](https://github.com/4gray/iptvnator/pull/1544))
|
||||
- **tmdb** — Russian titles containing "й" or "ё" and Arabic titles with hamza letters now match on TMDB. The search used to send a folded spelling — dropping the breve from "й", for instance — that TMDB never recognised, and cached the miss for a week; those cached misses are cleared on the next start. ([#1626](https://github.com/4gray/iptvnator/pull/1626))
|
||||
- **tmdb** — A new series no longer picks up the poster, cast and plot of an older show that happens to share its title. Metadata is looked up in your own language, so an unrelated foreign series can be listed under the very same name — and the better-known one used to win. The release year the provider states now decides first. ([#1648](https://github.com/4gray/iptvnator/pull/1648))
|
||||
- **ui** — Live TV now supports keyboard movement between categories and channels, and channel lists keep scrolling after mouse selection. Channel scrollbars and resize handles are independently accessible. Movie and series details support immediate keyboard scrolling and no longer hide their scrollbars. ([#1542](https://github.com/4gray/iptvnator/pull/1542), closes [#1506](https://github.com/4gray/iptvnator/issues/1506))
|
||||
- **ui** — Detail buttons, episode cards and episode rows now keep visible backgrounds and borders in the light theme. The grid/list switch also clearly highlights the selected view in both themes. ([#1549](https://github.com/4gray/iptvnator/pull/1549))
|
||||
- **ui** — Movie and series detail pages now show one Back arrow instead of two. The floating arrow always returns to the list, while watching too, and the "Close player" button beside the player is the only control that returns to the description. Escape still closes the player first and then goes back. ([#1627](https://github.com/4gray/iptvnator/pull/1627))
|
||||
- **ui** — Movie and series detail pages keep their Back button visible while scrolling. Escape closes the inline player to the description, then returns to the previous view. Open menus, dialogs and fullscreen retain priority. ([#1576](https://github.com/4gray/iptvnator/pull/1576), closes [#1570](https://github.com/4gray/iptvnator/issues/1570))
|
||||
- **updater** — "What's new" no longer fails with a raw error for a build published after the app started: the release list is re-read from GitHub when the requested version is missing, so a freshly offered nightly shows its notes. When a version really has no release, the dialog now says so in plain words and links to the channel's releases page. ([#1631](https://github.com/4gray/iptvnator/pull/1631))
|
||||
- **web-backend** — The self-hosted backend now checks every provider redirect and pins connections to validated addresses, preventing redirects or DNS changes from bypassing private-network restrictions. Trusted LAN access remains available through the existing opt-in. ([#1553](https://github.com/4gray/iptvnator/pull/1553), closes [#1436](https://github.com/4gray/iptvnator/issues/1436))
|
||||
- **xtream** — Xtream Live TV in Auto can try advertised TS once when HLS initially fails with an HTTP error in a web player. The selected player and stream headers are kept. Settings explain the manual TS workaround for external players, Embedded MPV, and HLS retries that never report a terminal failure. ([#1558](https://github.com/4gray/iptvnator/pull/1558), closes [#1513](https://github.com/4gray/iptvnator/issues/1513))
|
||||
- **xtream** — Catch-up (timeshift) from the Favorites and Recent tabs now asks the panel for the programme you clicked: the start time is rendered in the panel's own timezone instead of your computer's. The panel's timezone is remembered per source, survives restarts, and panels that report an unusual timezone name are handled through their clock. ([#1563](https://github.com/4gray/iptvnator/pull/1563), closes [#1562](https://github.com/4gray/iptvnator/issues/1562))
|
||||
- **epg** — The current program shown under each channel in the Xtream Live TV list now moves on by itself once that program ends, and its progress bar keeps advancing. Previously both stayed frozen on the old program until you left the category and came back in. ([#1647](https://github.com/4gray/iptvnator/pull/1647), closes [#767](https://github.com/4gray/iptvnator/issues/767))
|
||||
- **xtream** — Xtream category management now selects or deselects only matching categories while searching, preserving the visibility of all other categories. Button labels and availability reflect the search results, and the counter clearly shows the total selected. ([#1534](https://github.com/4gray/iptvnator/pull/1534), closes [#812](https://github.com/4gray/iptvnator/issues/812), closes [#816](https://github.com/4gray/iptvnator/issues/816))
|
||||
- **xtream** — The explicit HTTPS and HTTP connection test now detects Xtream portals that accept HTTP when HTTPS is unavailable and fills in the working address before you save. The saved address is used for catalog updates, provider EPG and playback. Connection failures now show a more specific explanation. ([#1588](https://github.com/4gray/iptvnator/pull/1588))
|
||||
- **xtream** — Opening a live channel in another Xtream playlist (from global search, the dashboard or the new "open in playlist" chip) no longer lands on an empty Live TV page: the jump used to get lost while the app was still opening that playlist, and now waits for its catalog before selecting and playing the channel. ([#1634](https://github.com/4gray/iptvnator/pull/1634))
|
||||
- **xtream** — The Xtream sync panel now keeps its status text, labels and Stop sync button clearly readable in both light and dark themes. ([#1546](https://github.com/4gray/iptvnator/pull/1546))
|
||||
|
||||
### Performance
|
||||
|
||||
- **database** — Refreshing or removing a large Xtream playlist is several times faster: the "Removing cached content" stage and the re-import now commit around 5,000 rows at a time instead of 100, and progress updates arrive at most ten times a second instead of once per batch. ([#1511](https://github.com/4gray/iptvnator/pull/1511), closes [#1292](https://github.com/4gray/iptvnator/issues/1292))
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
- **dashboard** — The dashboard's live "now playing" time range and progress bar now read pre-computed EPG timestamps as unix seconds, matching the rest of the EPG code. Today's dashboard lookups never carry those fields, so nothing changes on screen; this closes the gap before a future data path supplies them. ([#1532](https://github.com/4gray/iptvnator/pull/1532))
|
||||
- **deps** — The application uses Angular 22 and Nx 23 with updated build and test tooling. ([#1603](https://github.com/4gray/iptvnator/pull/1603))
|
||||
- **deps** — The Embedded MPV native addon build now declares its `node-gyp` dependency explicitly, so `node apps/electron-backend/build-embedded-mpv.js` and the Homebrew development scripts work on a clean checkout instead of failing with "Unable to resolve node-gyp". Packaged builds are unchanged. ([#1625](https://github.com/4gray/iptvnator/pull/1625))
|
||||
- **deps** — Closes the eight open Dependabot alerts of September 2026: astro 7.2.4 → 7.2.10 (website build), and pinned pnpm overrides moving js-yaml to 4.3.2 (the one runtime path, via electron-updater), smol-toml to 1.7.1, svgo to 4.1.0 and hono to 4.13.5. No application behavior changes. ([#1635](https://github.com/4gray/iptvnator/pull/1635))
|
||||
- **deps** — Closes the four open Dependabot alerts on transitive npm dependencies via pinned pnpm overrides: browserslist (crash on untrusted stats), @xmldom/xmldom (XML fragment injection — its existing override target had itself fallen into the advisory range), @humanfs/node (recursive copy follows symlinks out of the tree) and postcss-selector-parser (AST recursion DoS). ([#1527](https://github.com/4gray/iptvnator/pull/1527))
|
||||
|
||||
</details>
|
||||
|
||||
# [0.23.0](https://github.com/4gray/iptvnator/compare/v0.22.0...v0.23.0) (2026-08-30)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -53,7 +53,7 @@ The application is a cross-platform, open-source project built with Electron and
|
||||
**Discovery & metadata**
|
||||
|
||||
- Global search across live TV, movies, and series _(desktop)_
|
||||
- TMDB enrichment (opt-in) — plots, cast & crew, trailers, ratings, artwork, a "Similar" rail, clickable actor pages, and a trending dashboard rail _(trending rail: desktop)_
|
||||
- TMDB enrichment (opt-in, requires your own TMDB API key) — plots, cast & crew, trailers, ratings, artwork, a "Similar" rail, clickable actor pages, and a trending dashboard rail _(trending rail: desktop)_
|
||||
- Dashboard with recently watched & continue-watching
|
||||
|
||||
**Organization**
|
||||
@@ -146,6 +146,18 @@ Download the latest version of the application for macOS, Windows, and Linux fro
|
||||
|
||||
Alternatively, you can install the application using one of the following package managers:
|
||||
|
||||
### Nightly builds
|
||||
|
||||
Every merge into `master` is published as a prerelease in
|
||||
[4gray/iptvnator-nightly](https://github.com/4gray/iptvnator-nightly). The
|
||||
desktop app can follow them: **Settings → About → Update channel → Nightly**
|
||||
offers each new build through the built-in updater (macOS, Windows, and the
|
||||
Linux AppImage). Nightly builds are untested snapshots — they may break, and
|
||||
their database changes are permanent, so switching back to **Stable** keeps
|
||||
the installed nightly until the next stable release is newer. Back up your
|
||||
playlists first, and mention the version and commit shown in *About* when
|
||||
reporting a bug from a nightly.
|
||||
|
||||
### Homebrew
|
||||
|
||||
```shell
|
||||
@@ -272,11 +284,18 @@ needed.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 22.13–22.x or 24 and newer with pnpm (via Corepack)
|
||||
- Node.js 22.22.3 or newer within 22.x, or 24.15.0 or newer within 24.x
|
||||
- pnpm 10.33.0 (via Corepack)
|
||||
|
||||
The repository's `.nvmrc` pins the Node.js version used by CI. With nvm,
|
||||
run `nvm install` and `nvm use` from the repository root to use the same version
|
||||
for local development.
|
||||
|
||||
1. Clone this repository and install project dependencies:
|
||||
|
||||
```
|
||||
$ nvm install
|
||||
$ nvm use
|
||||
$ corepack enable
|
||||
$ pnpm install
|
||||
```
|
||||
@@ -370,3 +389,11 @@ The name **"IPTVnator"** and the IPTVnator logo are unregistered trademarks of t
|
||||
[](#contributors)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
## Developer and agent documentation
|
||||
|
||||
Start with the [task context map](docs/maintenance/agent-context-map.md) to find
|
||||
the authoritative contract and validation for your area. Common agent rules are
|
||||
in [AGENTS.md](AGENTS.md); Claude Code imports that same file. Development and
|
||||
documentation-maintenance conventions live in the
|
||||
[agent workflow](docs/development/agent-workflow.md).
|
||||
@@ -590,8 +590,8 @@ async function firstVisibleGridTitle(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
@@ -47,6 +48,33 @@ test.describe('Dashboard Activation', () => {
|
||||
liveFixture.items,
|
||||
getXtreamTitle
|
||||
);
|
||||
// The mock's guide for the channel favourited below (its first live
|
||||
// stream). The programme on air is read off the full guide at
|
||||
// assertion time: the mock cuts its slots from the second the guide
|
||||
// was generated, so the "current" listing of a fixture fetched in
|
||||
// that same second is the slot that ended just then.
|
||||
const epgFixture = await fetchXtreamEpgFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle);
|
||||
const liveNowTitle = () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const index = epgFixture.fullEpg.findIndex(
|
||||
(listing) =>
|
||||
listing.startTimestamp <= nowSeconds &&
|
||||
nowSeconds < listing.stopTimestamp
|
||||
);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
// Tolerate a slot boundary passing between the app's answer and
|
||||
// this assertion.
|
||||
const titles = epgFixture.fullEpg
|
||||
.slice(index, index + 2)
|
||||
.map((listing) =>
|
||||
listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
);
|
||||
return new RegExp(titles.join('|'));
|
||||
};
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
@@ -139,6 +167,15 @@ test.describe('Dashboard Activation', () => {
|
||||
app.mainWindow,
|
||||
'dashboard-live-favorites-rail'
|
||||
);
|
||||
// An Xtream card has no XMLTV key: its "now on air" line comes
|
||||
// from the portal, asked for lazily once the card is on screen.
|
||||
await expect(
|
||||
dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-live-favorites-rail',
|
||||
liveTitle
|
||||
).locator('.rail__channel-now')
|
||||
).toContainText(liveNowTitle(), { timeout: 30000 });
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-live-favorites-rail',
|
||||
@@ -167,6 +204,14 @@ test.describe('Dashboard Activation', () => {
|
||||
app.mainWindow,
|
||||
'dashboard-recent-live-rail'
|
||||
);
|
||||
// Same channel, same key: the recent card shares the answer.
|
||||
await expect(
|
||||
dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recent-live-rail',
|
||||
liveTitle
|
||||
).locator('.rail__channel-now')
|
||||
).toContainText(liveNowTitle(), { timeout: 30000 });
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recent-live-rail',
|
||||
@@ -249,8 +294,8 @@ function dashboardRailCardByTitle(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { terminateElectronProcess } from './electron-process-termination';
|
||||
|
||||
type ElectronChildProcess = Pick<
|
||||
ChildProcess,
|
||||
'exitCode' | 'kill' | 'once' | 'removeListener' | 'signalCode'
|
||||
'exitCode' | 'kill' | 'once' | 'pid' | 'removeListener' | 'signalCode'
|
||||
>;
|
||||
|
||||
export interface ClosableElectronApplication {
|
||||
close(): Promise<void>;
|
||||
process(): ElectronChildProcess;
|
||||
}
|
||||
|
||||
export interface ElectronExitConfirmationOptions {
|
||||
@@ -15,6 +15,19 @@ export interface ElectronExitConfirmationOptions {
|
||||
readonly exitTimeoutMs: number;
|
||||
}
|
||||
|
||||
const electronProcesses = new WeakMap<
|
||||
ClosableElectronApplication,
|
||||
ElectronChildProcess
|
||||
>();
|
||||
|
||||
export function captureElectronProcess<Process extends ElectronChildProcess>(
|
||||
application: ClosableElectronApplication & { process(): Process }
|
||||
): Process {
|
||||
const child = application.process();
|
||||
electronProcesses.set(application, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export interface PrepareElectronApplicationOptions<Application, Prepared> {
|
||||
readonly application: Application;
|
||||
readonly dispose: (application: Application) => Promise<void>;
|
||||
@@ -49,11 +62,19 @@ export async function prepareElectronApplication<Application, Prepared>(
|
||||
|
||||
export async function closeElectronApplicationAndConfirmExit(
|
||||
application: ClosableElectronApplication,
|
||||
options: ElectronExitConfirmationOptions
|
||||
options: ElectronExitConfirmationOptions,
|
||||
terminate: (
|
||||
child: ElectronChildProcess,
|
||||
signal: NodeJS.Signals
|
||||
) => void = terminateElectronProcess
|
||||
): Promise<void> {
|
||||
assertTimeout(options.closeTimeoutMs);
|
||||
assertTimeout(options.exitTimeoutMs);
|
||||
const child = application.process();
|
||||
// Playwright discards its process dispatcher when Electron exits. Keep
|
||||
// using the Node handle captured immediately after launch, including when
|
||||
// the application's last window already caused a normal process exit.
|
||||
const child = electronProcesses.get(application);
|
||||
if (!child) throw new Error('electron-process-handle-not-captured');
|
||||
if (hasExited(child)) return;
|
||||
|
||||
const exit = observeProcessExit(child);
|
||||
@@ -82,14 +103,19 @@ export async function closeElectronApplicationAndConfirmExit(
|
||||
}
|
||||
if (first.kind === 'close-rejected') failure = first.failure;
|
||||
|
||||
try {
|
||||
child.kill();
|
||||
} catch (killFailure) {
|
||||
failure = failure
|
||||
? new AggregateError([failure, killFailure])
|
||||
: killFailure;
|
||||
// A failed termination must not skip exit observation or let a
|
||||
// caller restart against a profile that is still owned by Electron.
|
||||
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
|
||||
try {
|
||||
terminate(child, signal);
|
||||
} catch (killFailure) {
|
||||
failure = failure
|
||||
? new AggregateError([failure, killFailure])
|
||||
: killFailure;
|
||||
}
|
||||
if (await resolvesWithin(exit.promise, options.exitTimeoutMs))
|
||||
return;
|
||||
}
|
||||
if (await resolvesWithin(exit.promise, options.exitTimeoutMs)) return;
|
||||
} finally {
|
||||
exit.cancel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execFileSync, type ChildProcess } from 'node:child_process';
|
||||
|
||||
type ElectronProcess = Pick<ChildProcess, 'pid' | 'kill'>;
|
||||
|
||||
export function terminateElectronProcess(
|
||||
child: ElectronProcess,
|
||||
signal: NodeJS.Signals = 'SIGTERM',
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: typeof execFileSync = execFileSync
|
||||
): void {
|
||||
// Playwright launches Electron through cmd.exe on Windows. Killing only
|
||||
// that ChildProcess leaves Electron (and its profile lock) alive.
|
||||
if (platform === 'win32') {
|
||||
if (!Number.isSafeInteger(child.pid) || (child.pid ?? 0) <= 0) {
|
||||
throw new Error('electron-process-pid-unavailable');
|
||||
}
|
||||
run('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Page,
|
||||
test as base,
|
||||
} from '@playwright/test';
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { createServer, Server } from 'http';
|
||||
import {
|
||||
accessSync,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
writeDataDirOwnerMarker,
|
||||
} from './data-dir-reaper';
|
||||
import {
|
||||
captureElectronProcess,
|
||||
closeElectronApplicationAndConfirmExit,
|
||||
prepareElectronApplication,
|
||||
} from './electron-process-lifecycle';
|
||||
@@ -234,6 +235,7 @@ export async function launchElectronApp(
|
||||
args,
|
||||
env: buildElectronLaunchEnvironment(dataDir, options),
|
||||
});
|
||||
const electronProcess = captureElectronProcess(electronApp);
|
||||
return prepareElectronApplication({
|
||||
application: electronApp,
|
||||
dispose: (application) =>
|
||||
@@ -242,7 +244,7 @@ export async function launchElectronApp(
|
||||
exitTimeoutMs: electronAppKillWaitMs,
|
||||
}),
|
||||
prepare: async (application) => {
|
||||
attachElectronProcessDiagnostics(application);
|
||||
attachElectronProcessDiagnostics(electronProcess);
|
||||
const mainWindow = await findMainWindow(application);
|
||||
await waitForAppReady(mainWindow);
|
||||
await startPortalDebugCapture(mainWindow);
|
||||
@@ -458,26 +460,31 @@ export async function launchPackagedElectronApp(
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
});
|
||||
attachElectronProcessDiagnostics(electronApp);
|
||||
|
||||
const mainWindow = await findMainWindow(electronApp);
|
||||
await waitForAppReady(mainWindow);
|
||||
|
||||
return {
|
||||
electronApp,
|
||||
mainWindow,
|
||||
};
|
||||
const electronProcess = captureElectronProcess(electronApp);
|
||||
return prepareElectronApplication({
|
||||
application: electronApp,
|
||||
dispose: (application) =>
|
||||
closeElectronApplicationAndConfirmExit(application, {
|
||||
closeTimeoutMs: electronAppCloseTimeoutMs,
|
||||
exitTimeoutMs: electronAppKillWaitMs,
|
||||
}),
|
||||
prepare: async (application) => {
|
||||
attachElectronProcessDiagnostics(electronProcess);
|
||||
const mainWindow = await findMainWindow(application);
|
||||
await waitForAppReady(mainWindow);
|
||||
return {
|
||||
electronApp: application,
|
||||
mainWindow,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function attachElectronProcessDiagnostics(
|
||||
electronApp: ElectronApplication
|
||||
): void {
|
||||
function attachElectronProcessDiagnostics(childProcess: ChildProcess): void {
|
||||
if (!process.env['CI']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childProcess = electronApp.process();
|
||||
|
||||
childProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
console.log(`[electron stdout] ${chunk.toString().trimEnd()}`);
|
||||
});
|
||||
@@ -564,52 +571,7 @@ export async function launchCompetingElectronInstance(
|
||||
export async function closeElectronApp(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
try {
|
||||
const closePromise = app.electronApp.close();
|
||||
const closed = await waitForPromiseWithTimeout(
|
||||
closePromise,
|
||||
electronAppCloseTimeoutMs
|
||||
);
|
||||
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Electron app did not close within ${electronAppCloseTimeoutMs}ms; killing process`
|
||||
);
|
||||
const childProcess = app.electronApp.process();
|
||||
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill();
|
||||
}
|
||||
|
||||
await waitForPromiseWithTimeout(
|
||||
closePromise.catch(() => undefined),
|
||||
electronAppKillWaitMs
|
||||
);
|
||||
|
||||
// SIGTERM asks Electron for a graceful quit, which the app can
|
||||
// legitimately refuse — the unsaved-settings close guard cancels the
|
||||
// quit while it waits for an answer. A process that survives here
|
||||
// would outlive the test, hold its data dir, and time out the worker
|
||||
// teardown, so escalate to SIGKILL.
|
||||
if (
|
||||
childProcess.exitCode === null &&
|
||||
childProcess.signalCode === null
|
||||
) {
|
||||
console.warn(
|
||||
'Electron app survived SIGTERM; escalating to SIGKILL'
|
||||
);
|
||||
childProcess.kill('SIGKILL');
|
||||
await waitForPromiseWithTimeout(
|
||||
closePromise.catch(() => undefined),
|
||||
electronAppKillWaitMs
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to close Electron app cleanly:', error);
|
||||
}
|
||||
await closeElectronAppAndConfirmExit(app);
|
||||
}
|
||||
|
||||
export async function closeElectronAppAndConfirmExit(
|
||||
@@ -621,26 +583,6 @@ export async function closeElectronAppAndConfirmExit(
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPromiseWithTimeout(
|
||||
promise: Promise<unknown>,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.then(() => true),
|
||||
new Promise<boolean>((resolvePromise) => {
|
||||
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackagedRendererBuildIsElectronSafe(): void {
|
||||
if (!existsSync(packagedRendererIndexPath)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
@@ -233,10 +234,7 @@ test.describe('Electron EPG', () => {
|
||||
removedUrls: [],
|
||||
}));
|
||||
});
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
@@ -296,6 +294,106 @@ test.describe('Electron EPG', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron imports a local gzipped XMLTV file given as an absolute path', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const localGuide = join(dataDir, 'local guide.xml.gz');
|
||||
writeFileSync(localGuide, gzipSync(epgFixtureXml));
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('epg-source-formats')
|
||||
).toContainText('file://');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
const field = app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.first();
|
||||
await field.fill('guide.xml');
|
||||
// Material reveals errors once the control is touched.
|
||||
await field.blur();
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-source-row mat-error')
|
||||
).toBeVisible();
|
||||
await field.fill(localGuide);
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-source-row mat-error')
|
||||
).toHaveCount(0);
|
||||
|
||||
// Native dialogs cannot be driven from Playwright: stub them in
|
||||
// the main process. A hand-typed path is confirmed once through
|
||||
// the message box; a refused path is reported, never read.
|
||||
await app.electronApp.evaluate(({ dialog }) => {
|
||||
dialog.showMessageBox = (async () => ({
|
||||
response: 1,
|
||||
checkboxChecked: false,
|
||||
})) as typeof dialog.showMessageBox;
|
||||
});
|
||||
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-error'
|
||||
)
|
||||
).toContainText('was not allowed');
|
||||
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
|
||||
await app.electronApp.evaluate(({ dialog }) => {
|
||||
dialog.showMessageBox = (async () => ({
|
||||
response: 0,
|
||||
checkboxChecked: false,
|
||||
})) as typeof dialog.showMessageBox;
|
||||
});
|
||||
await app.mainWindow
|
||||
.locator('.epg-progress-panel .retry-btn')
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-complete'
|
||||
)
|
||||
).toHaveCount(1);
|
||||
|
||||
// A file chosen in the native picker is trusted without a prompt.
|
||||
const browsedGuide = join(dataDir, 'browsed guide.xml.gz');
|
||||
writeFileSync(browsedGuide, gzipSync(epgFixtureXml));
|
||||
await app.electronApp.evaluate(({ dialog }, filePath) => {
|
||||
dialog.showOpenDialog = (async () => ({
|
||||
canceled: false,
|
||||
filePaths: [filePath],
|
||||
})) as typeof dialog.showOpenDialog;
|
||||
dialog.showMessageBox = (async () => {
|
||||
throw new Error('picked files must not prompt');
|
||||
}) as typeof dialog.showMessageBox;
|
||||
}, browsedGuide);
|
||||
await app.mainWindow.getByTestId('epg-source-browse').click();
|
||||
await expect(field).toHaveValue(browsedGuide);
|
||||
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-complete'
|
||||
)
|
||||
).toHaveCount(2);
|
||||
|
||||
// Leave with a pristine form: a dirty settings form arms the
|
||||
// main-process close guard, and the app would then wait for a
|
||||
// confirmation dialog instead of closing.
|
||||
await app.mainWindow.getByTestId('discard-settings').click();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
@@ -316,10 +414,7 @@ test.describe('Electron EPG', () => {
|
||||
.first()
|
||||
.fill(epgServer.resourceUrl);
|
||||
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.first()
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel')
|
||||
).toBeVisible();
|
||||
@@ -334,12 +429,28 @@ test.describe('Electron EPG', () => {
|
||||
.first()
|
||||
).toBeVisible();
|
||||
|
||||
// The row must fit the 300px panel: Material icon buttons carry a
|
||||
// 48px touch target that used to poke past the row padding and
|
||||
// turn into a horizontal scrollbar.
|
||||
const importsList = app.mainWindow.locator(
|
||||
'.epg-progress-panel .imports-list'
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
importsList.evaluate(
|
||||
(element) => element.scrollWidth - element.clientWidth
|
||||
)
|
||||
)
|
||||
.toBe(0);
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel .item-url')
|
||||
).toHaveText(
|
||||
`${new URL(epgServer.resourceUrl).hostname}/guide.xml`
|
||||
);
|
||||
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
|
||||
0
|
||||
);
|
||||
@@ -417,10 +528,7 @@ test.describe('Electron EPG', () => {
|
||||
'.epg-progress-panel .import-item.status-error'
|
||||
)
|
||||
).toHaveCount(1);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel .import-item')
|
||||
@@ -483,8 +591,7 @@ test.describe('Electron EPG', () => {
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row')
|
||||
.first()
|
||||
.locator('button')
|
||||
.nth(1)
|
||||
.getByTestId('epg-source-remove')
|
||||
.click();
|
||||
// A staged removal must not delete data before Save.
|
||||
expect(await programs()).toContain('Removed Bulletin');
|
||||
@@ -504,10 +611,7 @@ test.describe('Electron EPG', () => {
|
||||
await expect.poll(programs).toEqual(['Retained Bulletin']);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect.poll(programs).toEqual([]);
|
||||
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
@@ -573,8 +677,7 @@ test.describe('Electron EPG', () => {
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row')
|
||||
.nth(1)
|
||||
.locator('button')
|
||||
.nth(1)
|
||||
.getByTestId('epg-source-remove')
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
const firstMetadata = {
|
||||
@@ -682,10 +785,7 @@ test.describe('Electron EPG', () => {
|
||||
.locator('.epg-source-row input')
|
||||
.fill(epgServer.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
// The same URL still belongs to the saved M3U playlist.
|
||||
const retained = await app.mainWindow.evaluate(async () =>
|
||||
@@ -701,6 +801,135 @@ test.describe('Electron EPG', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("@epg @electron dashboard live rails find a programme that only another playlist's XMLTV carries", async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
// The reported case: the favourited channel's own playlist declares
|
||||
// no guide, Settings hold a global XMLTV that does not know it, and
|
||||
// the programme exists only in the guide a DIFFERENT playlist
|
||||
// imported. The rails searched the global scope alone and showed
|
||||
// nothing, while the "See all" row — resolved unscoped — had it.
|
||||
const otherPlaylistEpgServer = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'playlist-guide-news',
|
||||
'Playlist Guide News',
|
||||
'Other Playlist Bulletin'
|
||||
),
|
||||
{
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/guides/other-playlist.xml',
|
||||
}
|
||||
);
|
||||
const globalEpgServer = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'global-other',
|
||||
'Global Other',
|
||||
'Global Other Bulletin'
|
||||
),
|
||||
{
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/guides/global-guide.xml',
|
||||
}
|
||||
);
|
||||
// Declares the guide, so importing it is what puts those programmes
|
||||
// in the database under that source.
|
||||
const guideOwnerServer = await createMutableTextServer(
|
||||
buildM3uContent([
|
||||
{
|
||||
name: 'Playlist Guide News',
|
||||
tvgId: 'playlist-guide-news',
|
||||
url: 'https://example.com/live/guide-owner.m3u8',
|
||||
},
|
||||
]).replace(
|
||||
'#EXTM3U',
|
||||
`#EXTM3U x-tvg-url="${otherPlaylistEpgServer.resourceUrl}"`
|
||||
),
|
||||
{
|
||||
contentType: 'application/x-mpegurl; charset=utf-8',
|
||||
resourcePath: '/guide-owner.m3u',
|
||||
}
|
||||
);
|
||||
// Carries the same XMLTV id under its own display name and declares
|
||||
// no guide at all — the playlist the dashboard card comes from.
|
||||
const guidelessServer = await createMutableTextServer(
|
||||
buildM3uContent([
|
||||
{
|
||||
name: 'Mirror News',
|
||||
tvgId: 'playlist-guide-news',
|
||||
url: 'https://example.com/live/mirror-news.m3u8',
|
||||
},
|
||||
]),
|
||||
{
|
||||
contentType: 'application/x-mpegurl; charset=utf-8',
|
||||
resourcePath: '/guideless.m3u',
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
guideOwnerServer.resourceUrl
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-complete'
|
||||
)
|
||||
).toHaveCount(1, { timeout: 30000 });
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.first()
|
||||
.fill(globalEpgServer.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
timeout: 30000,
|
||||
})
|
||||
.toBe(2);
|
||||
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
guidelessServer.resourceUrl
|
||||
);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'All channels');
|
||||
const channelItem = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
'Mirror News'
|
||||
);
|
||||
await expect(channelItem).toBeVisible({ timeout: 20000 });
|
||||
await channelItem.hover();
|
||||
await channelItem.locator('.favorite-button').first().click();
|
||||
await expect(
|
||||
channelItem.locator('.favorite-button mat-icon').first()
|
||||
).toHaveText(/star/);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
const card = app.mainWindow
|
||||
.locator('[data-test-id="dashboard-live-favorites-rail-card"]')
|
||||
.filter({ hasText: 'Mirror News' })
|
||||
.first();
|
||||
await expect(card).toBeVisible({ timeout: 20000 });
|
||||
await expect(card.locator('.rail__channel-now')).toContainText(
|
||||
'Other Playlist Bulletin',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await guidelessServer.close();
|
||||
await guideOwnerServer.close();
|
||||
await otherPlaylistEpgServer.close();
|
||||
await globalEpgServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
@@ -752,10 +981,7 @@ test.describe('Electron EPG', () => {
|
||||
.locator('.epg-source-row input')
|
||||
.first()
|
||||
.fill(epgServer.resourceUrl);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.first()
|
||||
.click();
|
||||
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
||||
|
||||
await expect
|
||||
.poll(() => getEpgChannelCount(app.mainWindow), {
|
||||
|
||||
@@ -606,8 +606,8 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
openSettingsSection,
|
||||
} from './electron-test-fixtures';
|
||||
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
|
||||
import { captureElectronProcess } from './electron-process-lifecycle';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
interface StartupTestGlobals {
|
||||
@@ -94,6 +95,7 @@ require(${JSON.stringify(electronMainPath)});`
|
||||
],
|
||||
env: buildElectronLaunchEnvironment(dataDir),
|
||||
});
|
||||
captureElectronProcess(app);
|
||||
const page = await app.firstWindow();
|
||||
try {
|
||||
// The route is intentionally still empty while recovery is pending.
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
|
||||
import {
|
||||
closeElectronApplicationAndConfirmExit,
|
||||
captureElectronProcess,
|
||||
closeElectronApplicationAndConfirmExit as closeApplication,
|
||||
ElectronApplicationDisposalError,
|
||||
prepareElectronApplication,
|
||||
} from '../electron-process-lifecycle';
|
||||
|
||||
// Fake children have no OS PID; exercise lifecycle decisions independently
|
||||
// of the platform process-tree integration test.
|
||||
const closeElectronApplicationAndConfirmExit: typeof closeApplication = (
|
||||
app,
|
||||
options
|
||||
) =>
|
||||
closeApplication(app, options, (child, signal) => {
|
||||
child.kill(signal);
|
||||
});
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
@@ -25,6 +40,94 @@ class FakeChildProcess extends EventEmitter {
|
||||
}
|
||||
|
||||
describe('Electron process lifecycle', () => {
|
||||
it('rejects cleanup when no process was captured for that application', async () => {
|
||||
await assert.rejects(
|
||||
closeApplication(
|
||||
{ close: async () => assert.fail('exit cannot be confirmed') },
|
||||
{ closeTimeoutMs: 1, exitTimeoutMs: 1 }
|
||||
),
|
||||
/electron-process-handle-not-captured/
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans the replacement application after a partial restart assignment', async () => {
|
||||
const oldChild = new FakeChildProcess();
|
||||
const newChild = new FakeChildProcess();
|
||||
const oldApplication = {
|
||||
close: async () => {
|
||||
oldChild.exitCode = 0;
|
||||
oldChild.emit('exit', 0, null);
|
||||
},
|
||||
process: () => oldChild,
|
||||
};
|
||||
const newApplication = {
|
||||
close: async () => {
|
||||
newChild.exitCode = 0;
|
||||
newChild.emit('exit', 0, null);
|
||||
},
|
||||
process: () => newChild,
|
||||
};
|
||||
const app = {
|
||||
electronApp: oldApplication,
|
||||
} as unknown as LaunchedElectronApp;
|
||||
captureElectronProcess(oldApplication);
|
||||
captureElectronProcess(newApplication);
|
||||
await closeElectronApp(app);
|
||||
// Existing E2E callers replace the application/window fields only.
|
||||
app.electronApp =
|
||||
newApplication as unknown as LaunchedElectronApp['electronApp'];
|
||||
|
||||
await closeElectronApp(app);
|
||||
assert.equal(newChild.exitCode, 0);
|
||||
});
|
||||
|
||||
it('confirms an already exited process after Playwright disposes its dispatcher', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const app = {
|
||||
electronApp: {
|
||||
close: async () => {
|
||||
assert.fail(
|
||||
'an exited application must not be closed again'
|
||||
);
|
||||
},
|
||||
process: () => child,
|
||||
},
|
||||
} as unknown as LaunchedElectronApp;
|
||||
|
||||
captureElectronProcess(app.electronApp);
|
||||
child.exitCode = 0;
|
||||
app.electronApp.process = () => {
|
||||
throw new TypeError(
|
||||
"Cannot read properties of undefined (reading '_object')"
|
||||
);
|
||||
};
|
||||
await closeElectronApp(app);
|
||||
assert.equal(child.killCalls, 0);
|
||||
});
|
||||
|
||||
it('does not return from public cleanup when close and termination both fail', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
child.kill = () => {
|
||||
throw new Error('termination failed');
|
||||
};
|
||||
const app = {
|
||||
electronApp: {
|
||||
close: async () => {
|
||||
throw new Error('CDP disconnected');
|
||||
},
|
||||
process: () => child,
|
||||
},
|
||||
} as unknown as LaunchedElectronApp;
|
||||
|
||||
captureElectronProcess(app.electronApp);
|
||||
app.electronApp.process = () => {
|
||||
assert.fail('cleanup must use the retained child process');
|
||||
};
|
||||
await assert.rejects(
|
||||
closeElectronApp(app),
|
||||
/electron-process-exit-unconfirmed/
|
||||
);
|
||||
});
|
||||
it('closes and confirms exit when post-spawn launch preparation fails', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const launchFailure = new Error('renderer readiness failed');
|
||||
@@ -37,6 +140,7 @@ describe('Electron process lifecycle', () => {
|
||||
},
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await assert.rejects(
|
||||
prepareElectronApplication({
|
||||
@@ -56,6 +160,40 @@ describe('Electron process lifecycle', () => {
|
||||
assert.equal(child.exitCode, 0);
|
||||
});
|
||||
|
||||
it('preserves preparation failure when Electron has already exited', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const launchFailure = new Error('renderer closed before readiness');
|
||||
const application = {
|
||||
close: async () => {
|
||||
assert.fail('an exited application must not be closed again');
|
||||
},
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await assert.rejects(
|
||||
prepareElectronApplication({
|
||||
application,
|
||||
dispose: (app) =>
|
||||
closeElectronApplicationAndConfirmExit(app, {
|
||||
closeTimeoutMs: 10,
|
||||
exitTimeoutMs: 10,
|
||||
}),
|
||||
prepare: async () => {
|
||||
child.exitCode = 0;
|
||||
application.process = () => {
|
||||
assert.fail(
|
||||
'the Playwright dispatcher is already disposed'
|
||||
);
|
||||
};
|
||||
throw launchFailure;
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error === launchFailure
|
||||
);
|
||||
assert.equal(child.killCalls, 0);
|
||||
});
|
||||
|
||||
it('fails when process exit remains unconfirmed after forced teardown', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
child.shouldExitOnKill = false;
|
||||
@@ -63,6 +201,7 @@ describe('Electron process lifecycle', () => {
|
||||
close: () => new Promise<void>(() => undefined),
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await assert.rejects(
|
||||
closeElectronApplicationAndConfirmExit(application, {
|
||||
@@ -71,7 +210,7 @@ describe('Electron process lifecycle', () => {
|
||||
}),
|
||||
/electron-process-exit-unconfirmed/
|
||||
);
|
||||
assert.equal(child.killCalls, 1);
|
||||
assert.equal(child.killCalls, 2);
|
||||
});
|
||||
|
||||
it('preserves both preparation and unconfirmed-disposal failures', async () => {
|
||||
@@ -107,6 +246,7 @@ describe('Electron process lifecycle', () => {
|
||||
},
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await Promise.race([
|
||||
closeElectronApplicationAndConfirmExit(application, {
|
||||
@@ -129,6 +269,7 @@ describe('Electron process lifecycle', () => {
|
||||
close: () => new Promise<void>(() => undefined),
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await closeElectronApplicationAndConfirmExit(application, {
|
||||
closeTimeoutMs: 1,
|
||||
@@ -137,4 +278,25 @@ describe('Electron process lifecycle', () => {
|
||||
assert.equal(child.killCalls, 1);
|
||||
assert.equal(child.signalCode, 'SIGTERM');
|
||||
});
|
||||
|
||||
it('still observes exit after the first termination attempt throws', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const signals: NodeJS.Signals[] = [];
|
||||
const application = {
|
||||
close: () => new Promise<void>(() => undefined),
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
await closeApplication(
|
||||
application,
|
||||
{ closeTimeoutMs: 1, exitTimeoutMs: 1 },
|
||||
(_child, signal) => {
|
||||
signals.push(signal);
|
||||
if (signal === 'SIGTERM') throw new Error('termination failed');
|
||||
child.kill();
|
||||
}
|
||||
);
|
||||
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
|
||||
assert.equal(child.signalCode, 'SIGTERM');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type execFileSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { describe, it } from 'node:test';
|
||||
import { terminateElectronProcess } from '../electron-process-termination';
|
||||
|
||||
describe('Electron forced termination', () => {
|
||||
it('terminates the Windows shell and its Electron descendants together', () => {
|
||||
let shellAlive = true;
|
||||
let electronAlive = true;
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: () => {
|
||||
shellAlive = false;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const run = ((file, args, options) => {
|
||||
assert.equal(file, 'taskkill.exe');
|
||||
assert.deepEqual(args, ['/pid', '1234', '/T', '/F']);
|
||||
assert.equal(options?.timeout, 5000);
|
||||
shellAlive = false;
|
||||
electronAlive = false;
|
||||
return Buffer.alloc(0);
|
||||
}) as typeof execFileSync;
|
||||
|
||||
terminateElectronProcess(child, 'SIGTERM', 'win32', run);
|
||||
|
||||
assert.equal(shellAlive, false);
|
||||
assert.equal(
|
||||
electronAlive,
|
||||
false,
|
||||
'Electron must release the profile before relaunch'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves signal escalation on Unix', () => {
|
||||
const signals: NodeJS.Signals[] = [];
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: (signal: NodeJS.Signals) => {
|
||||
signals.push(signal);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
terminateElectronProcess(child, 'SIGTERM', 'linux');
|
||||
terminateElectronProcess(child, 'SIGKILL', 'darwin');
|
||||
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
|
||||
});
|
||||
|
||||
it('does not fall back to killing only the shell when tree termination fails', () => {
|
||||
const failure = new Error('taskkill failed');
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: () => assert.fail('would orphan the Electron descendant'),
|
||||
};
|
||||
const run = (() => {
|
||||
throw failure;
|
||||
}) as typeof execFileSync;
|
||||
assert.throws(
|
||||
() => terminateElectronProcess(child, 'SIGTERM', 'win32', run),
|
||||
(error) => error === failure
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
'reaps a real Windows shell child instead of orphaning its descendant',
|
||||
{
|
||||
skip: process.platform !== 'win32',
|
||||
timeout: 15000,
|
||||
},
|
||||
async () => {
|
||||
// Match Playwright's Windows launch topology: cmd.exe -> application.
|
||||
const shell = spawn(
|
||||
`"${process.execPath}"`,
|
||||
[
|
||||
'-e',
|
||||
'"setInterval(() => {}, 1000); console.log(process.pid)"',
|
||||
],
|
||||
{ shell: true, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
);
|
||||
try {
|
||||
const [output] = await once(shell.stdout, 'data', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const descendantPid = Number(String(output).trim());
|
||||
assert.ok(
|
||||
Number.isSafeInteger(descendantPid) && descendantPid > 0
|
||||
);
|
||||
assert.notEqual(descendantPid, shell.pid);
|
||||
process.kill(descendantPid, 0);
|
||||
|
||||
terminateElectronProcess(shell);
|
||||
await assert.rejects(
|
||||
async () => process.kill(descendantPid, 0),
|
||||
{
|
||||
code: 'ESRCH',
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (shell.exitCode === null && shell.signalCode === null) {
|
||||
// The successful taskkill can precede Node's exit event.
|
||||
try {
|
||||
terminateElectronProcess(shell);
|
||||
} catch {
|
||||
/* already exited */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -653,8 +653,8 @@ async function expectUnifiedLiveDetailOpen(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
expectPathname,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openSources,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
expectRendererReloadedOnRoute,
|
||||
reloadFromMainProcess,
|
||||
reloadFromRenderer,
|
||||
} from './renderer-reload.support';
|
||||
|
||||
/**
|
||||
* The packaged renderer is index.html over file:// with path routing, so
|
||||
* once the user is on a section the document URL names a path with no file
|
||||
* behind it. A main-process reload of that URL used to fail with
|
||||
* ERR_FILE_NOT_FOUND and leave the window on Chromium's error page until the
|
||||
* app restarted, and a renderer-initiated reload was cancelled by the
|
||||
* navigation guard and silently did nothing. Both now boot the app straight
|
||||
* back into the route it was on.
|
||||
*/
|
||||
test.describe('Renderer reload on an in-app route', () => {
|
||||
test('@electron @window a main-process reload keeps the app on the Sources page', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSources(app.mainWindow);
|
||||
|
||||
await reloadFromMainProcess(app);
|
||||
|
||||
await expectRendererReloadedOnRoute(
|
||||
app.mainWindow,
|
||||
/\/workspace\/sources$/
|
||||
);
|
||||
// A fresh data dir has no sources: the page shows its empty state.
|
||||
await expect(
|
||||
app.mainWindow.getByRole('heading', {
|
||||
name: 'Add your first playlist',
|
||||
})
|
||||
).toBeVisible();
|
||||
// The page is alive, not a leftover paint: navigation still works.
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Dashboard', exact: true })
|
||||
.click();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @window a renderer-initiated reload keeps the app on its settings section', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
|
||||
await reloadFromRenderer(app);
|
||||
|
||||
await expectRendererReloadedOnRoute(
|
||||
app.mainWindow,
|
||||
/\/workspace\/settings\/playback$/
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-container')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-section-playback')
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { LaunchedElectronApp } from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* Helpers for E2E that reload the packaged renderer on an in-app route.
|
||||
*
|
||||
* The URL before and after a recovered reload is the same routed `file://`
|
||||
* URL, so waiting on the URL alone passes before anything happened. The
|
||||
* current document is marked instead, and the wait is for a document
|
||||
* WITHOUT the mark that has reached the route.
|
||||
*/
|
||||
|
||||
const DOCUMENT_MARK = 'data-e2e-pre-reload';
|
||||
|
||||
async function markCurrentDocument(page: Page): Promise<void> {
|
||||
await page.evaluate((attribute) => {
|
||||
document.documentElement.setAttribute(attribute, '');
|
||||
}, DOCUMENT_MARK);
|
||||
}
|
||||
|
||||
/** What the macOS View › Reload menu role does: `webContents.reload()`. */
|
||||
export async function reloadFromMainProcess(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await markCurrentDocument(app.mainWindow);
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const [win] = BrowserWindow.getAllWindows();
|
||||
win.webContents.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the settings unsaved-changes guard does after a confirmed reload:
|
||||
* `window.location.reload()`. The evaluate may lose its execution context
|
||||
* to the navigation it starts; that is not a failure.
|
||||
*/
|
||||
export async function reloadFromRenderer(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await markCurrentDocument(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.evaluate(() => {
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until a NEW document is rendered on `pathname` — the app re-booted
|
||||
* on the route rather than the old document still being on screen — and
|
||||
* checks that the restore parameter was consumed on the way.
|
||||
*/
|
||||
export async function expectRendererReloadedOnRoute(
|
||||
page: Page,
|
||||
pathname: RegExp
|
||||
): Promise<void> {
|
||||
await expect(page.locator(`html[${DOCUMENT_MARK}]`)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(pathname);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
document.querySelector('app-root')?.innerHTML.trim()
|
||||
.length ?? 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
expect(new URL(page.url()).searchParams.has('restoreRoute')).toBe(false);
|
||||
}
|
||||
@@ -1540,8 +1540,8 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
// Return to the list: the shell's sticky Back is route-level in browse
|
||||
// and watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
openSources,
|
||||
restartElectronApp,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
expectRendererReloadedOnRoute,
|
||||
reloadFromMainProcess,
|
||||
} from './renderer-reload.support';
|
||||
|
||||
/**
|
||||
* Chromium's zoom factor as the renderer actually renders it: the window's
|
||||
* content width in device-independent pixels over the CSS pixels the page
|
||||
* sees. Independent of `getZoomLevel()`, which under file:// reports the
|
||||
* per-URL entry and can disagree with what is on screen.
|
||||
*/
|
||||
async function renderedZoomFactor(app: LaunchedElectronApp): Promise<number> {
|
||||
const contentWidth = await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const [win] = BrowserWindow.getAllWindows();
|
||||
return win.getContentSize()[0];
|
||||
});
|
||||
const innerWidth = await app.mainWindow.evaluate(() => window.innerWidth);
|
||||
return contentWidth / innerWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app zoom shortcuts as the renderer receives them: Cmd on macOS, Ctrl
|
||||
* elsewhere. Dispatched through CDP they reach the renderer's keydown
|
||||
* binding directly (`WorkspaceKeyboardShortcutsService`), which steps the
|
||||
* frame-bound level through the preload bridge; the macOS application menu
|
||||
* is not in this path.
|
||||
*/
|
||||
const ZOOM_MODIFIER = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
|
||||
async function pressZoomShortcut(
|
||||
app: LaunchedElectronApp,
|
||||
key: 'Equal' | 'Minus' | 'Digit0' | 'NumpadAdd' | 'NumpadSubtract',
|
||||
times = 1
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
await app.mainWindow.keyboard.press(`${ZOOM_MODIFIER}+${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cross-document navigation of the renderer (what a reload is for zoom:
|
||||
* Chromium drops the temporary level and the new document's preload must
|
||||
* restore it). A real reload of the routed `file://` URL: the main process
|
||||
* recovers the missing file by re-loading the index on the same route
|
||||
* (`renderer-reload.e2e.ts`).
|
||||
*/
|
||||
async function reloadRenderer(app: LaunchedElectronApp): Promise<void> {
|
||||
await reloadFromMainProcess(app);
|
||||
await expectRendererReloadedOnRoute(
|
||||
app.mainWindow,
|
||||
/\/workspace\/sources$/
|
||||
);
|
||||
}
|
||||
|
||||
async function resizeWindowBy(
|
||||
app: LaunchedElectronApp,
|
||||
delta: number
|
||||
): Promise<void> {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }, step) => {
|
||||
const [win] = BrowserWindow.getAllWindows();
|
||||
const [width, height] = win.getSize();
|
||||
win.setSize(width + step, height + step);
|
||||
}, delta);
|
||||
}
|
||||
|
||||
// Zoom level 1 is a 1.2 factor; each shortcut press steps the level by 0.5
|
||||
// (Electron's zoomIn/zoomOut role step). The packaged renderer runs under
|
||||
// file:// with path routing, where Chromium keys zoom by full URL: without
|
||||
// frame-bound (temporary) zoom the level "holds" only until the next resize
|
||||
// after a section change, and a restart brings back the default (issue #1109).
|
||||
const ZOOMED_FACTOR = 1.2;
|
||||
const HALF_STEP_FACTOR = Math.sqrt(ZOOMED_FACTOR);
|
||||
|
||||
test('@electron @window keeps the zoom level across sections, resizes and a restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
expect(await renderedZoomFactor(app)).toBeCloseTo(1, 1);
|
||||
|
||||
// In three times, out once (main keys and numpad): level 1.
|
||||
await pressZoomShortcut(app, 'Equal', 2);
|
||||
await pressZoomShortcut(app, 'NumpadAdd');
|
||||
await expect
|
||||
.poll(() => renderedZoomFactor(app))
|
||||
.toBeCloseTo(ZOOMED_FACTOR * HALF_STEP_FACTOR, 1);
|
||||
await pressZoomShortcut(app, 'Minus');
|
||||
await expect
|
||||
.poll(() => renderedZoomFactor(app))
|
||||
.toBeCloseTo(ZOOMED_FACTOR, 1);
|
||||
|
||||
// Reset returns to level 0, then zoom back in (three in, one out on
|
||||
// the numpad) for the persistence checks below.
|
||||
await pressZoomShortcut(app, 'Digit0');
|
||||
await expect.poll(() => renderedZoomFactor(app)).toBeCloseTo(1, 1);
|
||||
await pressZoomShortcut(app, 'Equal', 3);
|
||||
await pressZoomShortcut(app, 'NumpadSubtract');
|
||||
await expect
|
||||
.poll(() => renderedZoomFactor(app))
|
||||
.toBeCloseTo(ZOOMED_FACTOR, 1);
|
||||
|
||||
// A route change (new file:// URL) followed by the visual-properties
|
||||
// sync a resize forces: per-URL zoom snaps back to 1 here.
|
||||
await openSources(app.mainWindow);
|
||||
await resizeWindowBy(app, 24);
|
||||
await app.mainWindow.waitForTimeout(500);
|
||||
expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1);
|
||||
|
||||
// A reload rebuilds the document; the level must be saved before it
|
||||
// and re-applied by the new document's preload.
|
||||
await reloadRenderer(app);
|
||||
await expect
|
||||
.poll(() => renderedZoomFactor(app))
|
||||
.toBeCloseTo(ZOOMED_FACTOR, 1);
|
||||
} finally {
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(() => renderedZoomFactor(app))
|
||||
.toBeCloseTo(ZOOMED_FACTOR, 1);
|
||||
expect(
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0].webContents.getZoomLevel()
|
||||
)
|
||||
).toBe(1);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await resizeWindowBy(app, -24);
|
||||
await app.mainWindow.waitForTimeout(500);
|
||||
expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -96,7 +96,7 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E
|
||||
.toContain('Temporary XMLTV Bulletin');
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
@@ -190,7 +190,7 @@ test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after so
|
||||
.toContain('Retired Stalker Bulletin');
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
|
||||
await app.mainWindow.getByTestId('epg-source-remove').click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
|
||||
@@ -677,11 +677,15 @@ function copyGenericRuntimeToNativeBuild(runtime) {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// Upstream node-gyp, resolved as a declared devDependency. This used to scan
|
||||
// node_modules/.pnpm for the `@electron/node-gyp` fork, which was only ever in
|
||||
// the tree as a transitive of `@electron/rebuild` 3 — rebuild 4 moved to
|
||||
// upstream `node-gyp` and the scan started throwing. The Electron target is
|
||||
// selected through the npm_config_* env below, not by the binary.
|
||||
// Upstream node-gyp, declared as a root devDependency so this resolves from a
|
||||
// plain `node apps/electron-backend/build-embedded-mpv.js`. Until it was
|
||||
// declared it was only in the tree as a transitive of `@electron/rebuild`,
|
||||
// reachable through pnpm's hidden hoist (node_modules/.pnpm/node_modules) —
|
||||
// which pnpm's `.bin` shims put on NODE_PATH, so `pnpm nx …` and CI worked
|
||||
// while a direct invocation on a clean install threw. Before that, this
|
||||
// scanned node_modules/.pnpm for the `@electron/node-gyp` fork, which
|
||||
// rebuild 4 dropped for upstream `node-gyp`. The Electron target is selected
|
||||
// through the npm_config_* env below, not by the binary.
|
||||
function resolveNodeGypBin() {
|
||||
try {
|
||||
return require.resolve('node-gyp/bin/node-gyp.js');
|
||||
|
||||
@@ -562,6 +562,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'openEpgFileDialog',
|
||||
args: [],
|
||||
channel: 'EPG_OPEN_FILE_DIALOG',
|
||||
forwardedArgs: [],
|
||||
},
|
||||
{
|
||||
method: 'clearEpgData',
|
||||
args: [],
|
||||
|
||||
@@ -31,11 +31,14 @@ type MockIpcRenderer = {
|
||||
on: jest.Mock;
|
||||
off: jest.Mock;
|
||||
send: jest.Mock;
|
||||
sendSync: jest.Mock;
|
||||
};
|
||||
|
||||
let mockExposedApi: ExposedElectronApi | null;
|
||||
let mockIpcRenderer: MockIpcRenderer;
|
||||
let mockGetPathForFile: jest.Mock;
|
||||
let mockWebFrame: { getZoomLevel: jest.Mock; setZoomLevel: jest.Mock };
|
||||
let mockDocumentListeners: Map<string, () => void>;
|
||||
|
||||
function getExposedApi(): ExposedElectronApi {
|
||||
if (!mockExposedApi) {
|
||||
@@ -64,6 +67,19 @@ describe('main preload DB IPC contract', () => {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
send: jest.fn(),
|
||||
sendSync: jest.fn().mockReturnValue(1.5),
|
||||
};
|
||||
mockWebFrame = {
|
||||
getZoomLevel: jest.fn().mockReturnValue(0),
|
||||
setZoomLevel: jest.fn(),
|
||||
};
|
||||
// The preload defers webFrame.setZoomLevel to DOMContentLoaded; the
|
||||
// jest environment is node, so stand in for the parsing document.
|
||||
mockDocumentListeners = new Map();
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
readyState: 'loading',
|
||||
addEventListener: (event: string, listener: () => void) =>
|
||||
mockDocumentListeners.set(event, listener),
|
||||
};
|
||||
|
||||
jest.doMock('electron', () => ({
|
||||
@@ -75,6 +91,7 @@ describe('main preload DB IPC contract', () => {
|
||||
),
|
||||
},
|
||||
ipcRenderer: mockIpcRenderer,
|
||||
webFrame: mockWebFrame,
|
||||
webUtils: {
|
||||
getPathForFile: mockGetPathForFile,
|
||||
},
|
||||
@@ -85,6 +102,44 @@ describe('main preload DB IPC contract', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.dontMock('electron');
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
});
|
||||
|
||||
it('requests the persisted zoom level synchronously and applies it at DOMContentLoaded', () => {
|
||||
// Through webFrame (temporary zoom) so the app's pushState routing
|
||||
// under file:// cannot reset it, and only once the document is parsed:
|
||||
// an earlier webFrame.setZoomLevel leaves a hidden Linux/Windows
|
||||
// window without ready-to-show (issue #1109).
|
||||
expect(mockIpcRenderer.sendSync).toHaveBeenCalledWith(
|
||||
'WINDOW:GET_ZOOM_LEVEL'
|
||||
);
|
||||
expect(mockWebFrame.setZoomLevel).not.toHaveBeenCalled();
|
||||
expect(mockExposedApi).not.toBeNull();
|
||||
|
||||
mockDocumentListeners.get('DOMContentLoaded')?.();
|
||||
|
||||
expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5);
|
||||
expect(mockIpcRenderer.send).toHaveBeenCalledWith(
|
||||
'WINDOW:ZOOM_LEVEL_APPLIED'
|
||||
);
|
||||
});
|
||||
|
||||
it('steps the zoom shortcuts through webFrame without any IPC', () => {
|
||||
// Same frame-bound temporary zoom as the restore: a main-process
|
||||
// setZoomLevel would write the per-URL entry the app's file://
|
||||
// routing resets (issue #1109).
|
||||
mockWebFrame.getZoomLevel.mockReturnValue(1);
|
||||
mockIpcRenderer.invoke.mockClear();
|
||||
mockIpcRenderer.send.mockClear();
|
||||
|
||||
expect(mockExposedApi?.adjustZoomLevel('in')).toBe(1.5);
|
||||
expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5);
|
||||
|
||||
expect(mockExposedApi?.adjustZoomLevel('reset')).toBe(0);
|
||||
expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(0);
|
||||
|
||||
expect(mockIpcRenderer.invoke).not.toHaveBeenCalled();
|
||||
expect(mockIpcRenderer.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('covers every worker-backed DB operation exposed by the preload bridge', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
|
||||
import {
|
||||
APP_UPDATE_CHECK,
|
||||
APP_UPDATE_DOWNLOAD,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST,
|
||||
ANNOUNCE_PLAYLIST_OPEN_LISTENER,
|
||||
OPEN_FILE,
|
||||
WINDOW_GET_ZOOM_LEVEL,
|
||||
WINDOW_ZOOM_LEVEL_APPLIED,
|
||||
} from '@iptvnator/shared/interfaces/ipc-commands';
|
||||
import {
|
||||
attachEmbeddedMpvFrameView,
|
||||
@@ -19,6 +21,10 @@ import {
|
||||
createPreloadPerformanceCapture,
|
||||
toPreloadPerformanceTargetMethod,
|
||||
} from './preload-performance-capture';
|
||||
import {
|
||||
adjustFrameZoomLevel,
|
||||
applyPersistedZoomLevel,
|
||||
} from './preload-zoom-level';
|
||||
import {
|
||||
createXtreamPreloadPerformanceCapture,
|
||||
isXtreamPreloadPerformanceCaptureEnabled,
|
||||
@@ -275,6 +281,11 @@ function wrapElectronApi<T extends object>(api: T): T {
|
||||
) as T;
|
||||
}
|
||||
|
||||
const frameZoomPorts = {
|
||||
getZoomLevel: () => webFrame.getZoomLevel(),
|
||||
setZoomLevel: (level: number) => webFrame.setZoomLevel(level),
|
||||
};
|
||||
|
||||
const electronApi: ElectronBridgeApi = {
|
||||
// Remote control channel change listener
|
||||
onChannelChange: (
|
||||
@@ -422,6 +433,9 @@ const electronApi: ElectronBridgeApi = {
|
||||
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
|
||||
toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN),
|
||||
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
// Zoom shortcuts: same frame-bound webFrame route as the restore below;
|
||||
// synchronous, no IPC — persistence reads the level back in main.
|
||||
adjustZoomLevel: (action) => adjustFrameZoomLevel(frameZoomPorts, action),
|
||||
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
|
||||
onWindowStateChange: (
|
||||
callback: (state: ElectronBridgeWindowState) => void
|
||||
@@ -683,6 +697,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window),
|
||||
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
|
||||
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
|
||||
openEpgFileDialog: () => ipcRenderer.invoke('EPG_OPEN_FILE_DIALOG'),
|
||||
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
|
||||
reconcileEpgSources: (urls: string[]) =>
|
||||
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
|
||||
@@ -1166,4 +1181,22 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Restore the app zoom level (issue #1109). Must be webFrame (temporary,
|
||||
// frame-bound zoom), not a main-process setZoomLevel: under file:// Chromium
|
||||
// keys zoom by full URL, and the app's pushState routing would reset it on
|
||||
// the next resize. Applied at DOMContentLoaded, never earlier — see
|
||||
// preload-zoom-level.ts for the Linux/Windows ready-to-show trap.
|
||||
applyPersistedZoomLevel({
|
||||
requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL),
|
||||
...frameZoomPorts,
|
||||
whenDocumentParsed: (apply) => {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', apply, { once: true });
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
},
|
||||
notifyApplied: () => ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi));
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
adjustFrameZoomLevel,
|
||||
applyPersistedZoomLevel,
|
||||
} from './preload-zoom-level';
|
||||
|
||||
function createPorts(saved: unknown, current = 0) {
|
||||
const parsed: Array<() => void> = [];
|
||||
return {
|
||||
requestPersistedZoomLevel: jest.fn(() => saved),
|
||||
getZoomLevel: jest.fn(() => current),
|
||||
setZoomLevel: jest.fn(),
|
||||
whenDocumentParsed: jest.fn((apply: () => void) => {
|
||||
parsed.push(apply);
|
||||
}),
|
||||
notifyApplied: jest.fn(),
|
||||
/** Fire DOMContentLoaded. */
|
||||
parse(): void {
|
||||
for (const apply of parsed.splice(0)) apply();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyPersistedZoomLevel', () => {
|
||||
it('requests the level at once but applies it only once the document is parsed', () => {
|
||||
const ports = createPorts(-1.5);
|
||||
|
||||
expect(applyPersistedZoomLevel(ports)).toBe(-1.5);
|
||||
expect(ports.requestPersistedZoomLevel).toHaveBeenCalledTimes(1);
|
||||
// Before DOMContentLoaded webFrame.setZoomLevel would leave a hidden
|
||||
// Linux/Windows window without ready-to-show.
|
||||
expect(ports.setZoomLevel).not.toHaveBeenCalled();
|
||||
expect(ports.notifyApplied).not.toHaveBeenCalled();
|
||||
|
||||
ports.parse();
|
||||
|
||||
expect(ports.setZoomLevel).toHaveBeenCalledWith(-1.5);
|
||||
expect(ports.notifyApplied).toHaveBeenCalledTimes(1);
|
||||
expect(ports.getZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, undefined, Number.NaN, '2'])(
|
||||
're-applies the current level as a temporary zoom when %p is stored',
|
||||
(saved) => {
|
||||
const ports = createPorts(saved, 0.5);
|
||||
|
||||
expect(applyPersistedZoomLevel(ports)).toBe(0.5);
|
||||
ports.parse();
|
||||
|
||||
expect(ports.setZoomLevel).toHaveBeenCalledWith(0.5);
|
||||
expect(ports.notifyApplied).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('never throws out of the preload when the request fails', () => {
|
||||
const ports = createPorts(1);
|
||||
ports.requestPersistedZoomLevel.mockImplementation(() => {
|
||||
throw new Error('no main process');
|
||||
});
|
||||
|
||||
expect(applyPersistedZoomLevel(ports)).toBeNull();
|
||||
expect(ports.whenDocumentParsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not acknowledge a level that webFrame refused', () => {
|
||||
const ports = createPorts(1);
|
||||
ports.setZoomLevel.mockImplementation(() => {
|
||||
throw new Error('frame gone');
|
||||
});
|
||||
|
||||
applyPersistedZoomLevel(ports);
|
||||
expect(() => ports.parse()).not.toThrow();
|
||||
|
||||
expect(ports.notifyApplied).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjustFrameZoomLevel', () => {
|
||||
function createFrame(current: number) {
|
||||
return {
|
||||
getZoomLevel: jest.fn(() => current),
|
||||
setZoomLevel: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it('steps the frame level through webFrame and reports the applied level', () => {
|
||||
const frame = createFrame(0);
|
||||
|
||||
expect(adjustFrameZoomLevel(frame, 'in')).toBe(0.5);
|
||||
expect(frame.setZoomLevel).toHaveBeenCalledWith(0.5);
|
||||
|
||||
const zoomed = createFrame(0.5);
|
||||
expect(adjustFrameZoomLevel(zoomed, 'out')).toBe(0);
|
||||
expect(zoomed.setZoomLevel).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('resets to level 0', () => {
|
||||
const frame = createFrame(2.5);
|
||||
|
||||
expect(adjustFrameZoomLevel(frame, 'reset')).toBe(0);
|
||||
expect(frame.setZoomLevel).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('does not write when the level is already at the limit', () => {
|
||||
const frame = createFrame(6);
|
||||
|
||||
expect(adjustFrameZoomLevel(frame, 'in')).toBe(6);
|
||||
expect(frame.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the unchanged level when webFrame refuses the write', () => {
|
||||
const frame = createFrame(1);
|
||||
frame.setZoomLevel.mockImplementation(() => {
|
||||
throw new Error('frame gone');
|
||||
});
|
||||
|
||||
expect(adjustFrameZoomLevel(frame, 'in')).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Preload half of the app zoom level persistence (issue #1109).
|
||||
*
|
||||
* The level is requested synchronously at preload start and applied through
|
||||
* `webFrame.setZoomLevel`, which installs a temporary, frame-bound zoom level
|
||||
* instead of Chromium's per-URL entry — the only form that survives the
|
||||
* app's `pushState` routing under `file://` (see
|
||||
* `services/window-zoom-level.ts` for the mechanism). When nothing is stored
|
||||
* it re-applies the current level for the same reason: entering temporary
|
||||
* mode makes the very first zoom shortcut URL-independent too, and keeps
|
||||
* whatever per-URL level Chromium restored on its own for this document.
|
||||
*
|
||||
* The apply itself is deferred to `DOMContentLoaded`. Calling
|
||||
* `webFrame.setZoomLevel` earlier — at preload start, or from a `setTimeout`
|
||||
* — leaves a hidden window without a first frame on Linux and Windows:
|
||||
* `ready-to-show` never fires, `show()` never runs, and the renderer gets no
|
||||
* animation frames (the splash that `main.ts` removes in a
|
||||
* `requestAnimationFrame` stays forever). macOS is unaffected, which is why
|
||||
* only the packaged Linux/Windows E2E caught it. After the parser finishes
|
||||
* the call is harmless, and it still lands before the first Angular paint.
|
||||
*/
|
||||
|
||||
import {
|
||||
stepZoomLevel,
|
||||
type ZoomLevelAction,
|
||||
} from '@iptvnator/shared/interfaces/zoom-level';
|
||||
|
||||
export interface PreloadZoomLevelPorts {
|
||||
/** `ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL)` — the stored level or null. */
|
||||
requestPersistedZoomLevel(): unknown;
|
||||
/** `webFrame.getZoomLevel()` */
|
||||
getZoomLevel(): number;
|
||||
/** `webFrame.setZoomLevel(level)` */
|
||||
setZoomLevel(level: number): void;
|
||||
/** Runs `apply` once the document is parsed (DOMContentLoaded), or at once if it already is. */
|
||||
whenDocumentParsed(apply: () => void): void;
|
||||
/** `ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED)` — hands the main process ownership of the level. */
|
||||
notifyApplied(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the level that will be applied, or `null` when the request failed.
|
||||
* A failure here must never break the bridge: the preload continues and the
|
||||
* window merely keeps Chromium's default zoom behaviour for this load.
|
||||
*/
|
||||
export function applyPersistedZoomLevel(
|
||||
ports: PreloadZoomLevelPorts
|
||||
): number | null {
|
||||
try {
|
||||
const saved = ports.requestPersistedZoomLevel();
|
||||
const level =
|
||||
typeof saved === 'number' && Number.isFinite(saved)
|
||||
? saved
|
||||
: ports.getZoomLevel();
|
||||
|
||||
ports.whenDocumentParsed(() => {
|
||||
try {
|
||||
ports.setZoomLevel(level);
|
||||
ports.notifyApplied();
|
||||
} catch {
|
||||
// Same contract as below: never take the bridge down.
|
||||
}
|
||||
});
|
||||
return level;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge half of the zoom shortcuts (`window.electron.adjustZoomLevel`):
|
||||
* steps the frame's temporary level in place. Same `webFrame` route as the
|
||||
* restore above, so the result is URL-independent and `getZoomLevel()` in the
|
||||
* main process reports it for the close/quit/reload persistence. Returns the
|
||||
* level now applied; a refused write leaves the frame as it was and returns
|
||||
* its current level, so the caller never learns a level that is not on
|
||||
* screen.
|
||||
*/
|
||||
export function adjustFrameZoomLevel(
|
||||
ports: Pick<PreloadZoomLevelPorts, 'getZoomLevel' | 'setZoomLevel'>,
|
||||
action: ZoomLevelAction
|
||||
): number {
|
||||
const current = ports.getZoomLevel();
|
||||
const next = stepZoomLevel(current, action);
|
||||
|
||||
if (next !== current) {
|
||||
try {
|
||||
ports.setZoomLevel(next);
|
||||
} catch {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ jest.mock('./services/store.service', () => ({
|
||||
set: jest.fn(),
|
||||
},
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
ZOOM_LEVEL: 'zoomLevel',
|
||||
}));
|
||||
|
||||
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
|
||||
|
||||
@@ -37,6 +37,7 @@ jest.mock('./services/store.service', () => ({
|
||||
},
|
||||
STARTUP_WINDOW_MODE: 'startupWindowMode',
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
ZOOM_LEVEL: 'zoomLevel',
|
||||
}));
|
||||
|
||||
jest.mock('./services/embedded-mpv-frame-copy-platform.util', () => ({
|
||||
@@ -54,10 +55,11 @@ import {
|
||||
isTrustedRendererNavigationUrl,
|
||||
} from './app';
|
||||
import App from './app';
|
||||
import { app as electronApp, BrowserWindow, screen } from 'electron';
|
||||
import { app as electronApp, BrowserWindow, screen, shell } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { store } from './services/store.service';
|
||||
import { markZoomLevelApplied } from './services/window-zoom-level';
|
||||
import { getPendingFullScreenTarget } from './services/native-fullscreen-transitions';
|
||||
|
||||
type MockMainWindow = {
|
||||
@@ -80,6 +82,8 @@ type MockMainWindow = {
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
openDevTools: jest.Mock<void, []>;
|
||||
setWindowOpenHandler: jest.Mock<void, [unknown]>;
|
||||
getZoomLevel: jest.Mock<number, []>;
|
||||
isDestroyed: jest.Mock<boolean, []>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -102,6 +106,8 @@ function createMockMainWindow(): MockMainWindow {
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
openDevTools: jest.fn<void, []>(),
|
||||
setWindowOpenHandler: jest.fn<void, [unknown]>(),
|
||||
getZoomLevel: jest.fn<number, []>().mockReturnValue(0),
|
||||
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -487,6 +493,224 @@ describe('Electron app security helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom level persistence', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 };
|
||||
|
||||
function createWindowViaOnReady(): MockMainWindow {
|
||||
const mainWindow = createMockMainWindow();
|
||||
mainWindow.getNormalBounds.mockReturnValue(bounds);
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
getAppInternals().onReady();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// Every listener of the event fires, since the window registers
|
||||
// more than one `did-start-navigation` listener (zoom persistence
|
||||
// and the renderer reload recovery).
|
||||
function fireHandlers(
|
||||
calls: Array<[string, (...args: unknown[]) => void]>,
|
||||
eventName: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
const handlers = calls
|
||||
.filter(([name]) => name === eventName)
|
||||
.map(([, handler]) => handler);
|
||||
|
||||
expect(handlers.length).toBeGreaterThanOrEqual(1);
|
||||
for (const handler of handlers) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function fireWindowEvent(win: MockMainWindow, eventName: string): void {
|
||||
fireHandlers(win.on.mock.calls, eventName);
|
||||
}
|
||||
|
||||
/** The preload asked for the level: its getZoomLevel() is now the app's. */
|
||||
function preloadAppliedZoom(win: MockMainWindow, level: number): void {
|
||||
win.webContents.getZoomLevel.mockReturnValue(level);
|
||||
markZoomLevelApplied(
|
||||
win.webContents as unknown as Electron.WebContents
|
||||
);
|
||||
}
|
||||
|
||||
it('persists the zoom level next to the bounds when a window whose preload owns the level closes', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
preloadAppliedZoom(mainWindow, 1.5);
|
||||
|
||||
fireWindowEvent(mainWindow, 'close');
|
||||
|
||||
expect(store.set).toHaveBeenCalledWith('zoomLevel', 1.5);
|
||||
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
|
||||
});
|
||||
|
||||
it('saves only the bounds when the window closes before its preload applied the level', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// Before the preload handshake getZoomLevel() is Chromium's
|
||||
// per-URL default; saving it would clobber the persisted level.
|
||||
mainWindow.webContents.getZoomLevel.mockReturnValue(0);
|
||||
|
||||
fireWindowEvent(mainWindow, 'close');
|
||||
|
||||
expect(store.set).not.toHaveBeenCalledWith(
|
||||
'zoomLevel',
|
||||
expect.anything()
|
||||
);
|
||||
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
|
||||
});
|
||||
|
||||
it('does not read zoom from a destroyed webContents on close', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
preloadAppliedZoom(mainWindow, 1.5);
|
||||
mainWindow.webContents.isDestroyed.mockReturnValue(true);
|
||||
|
||||
fireWindowEvent(mainWindow, 'close');
|
||||
|
||||
expect(mainWindow.webContents.getZoomLevel).not.toHaveBeenCalled();
|
||||
expect(store.set).not.toHaveBeenCalledWith(
|
||||
'zoomLevel',
|
||||
expect.anything()
|
||||
);
|
||||
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
|
||||
});
|
||||
|
||||
it('persists the zoom level and bounds on before-quit', () => {
|
||||
const mainWindow = createMockMainWindow();
|
||||
mainWindow.getNormalBounds.mockReturnValue(bounds);
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
(electronApp.isReady as jest.Mock).mockReturnValue(true);
|
||||
App.main(electronApp, BrowserWindow);
|
||||
preloadAppliedZoom(mainWindow, 2);
|
||||
|
||||
fireHandlers(
|
||||
(electronApp.on as jest.Mock).mock.calls,
|
||||
'before-quit'
|
||||
);
|
||||
|
||||
expect(store.set).toHaveBeenCalledWith('zoomLevel', 2);
|
||||
expect(store.set).toHaveBeenCalledWith('windowBounds', bounds);
|
||||
});
|
||||
|
||||
it('saves the zoom level right before a reload, which drops the temporary level', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
preloadAppliedZoom(mainWindow, 3);
|
||||
|
||||
fireHandlers(
|
||||
mainWindow.webContents.on.mock.calls,
|
||||
'did-start-navigation',
|
||||
{ isMainFrame: true, isSameDocument: false }
|
||||
);
|
||||
|
||||
expect(store.set).toHaveBeenCalledWith('zoomLevel', 3);
|
||||
});
|
||||
|
||||
it('leaves in-app (same-document) routing alone', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
preloadAppliedZoom(mainWindow, 3);
|
||||
|
||||
fireHandlers(
|
||||
mainWindow.webContents.on.mock.calls,
|
||||
'did-start-navigation',
|
||||
{ isMainFrame: true, isSameDocument: true }
|
||||
);
|
||||
|
||||
expect(store.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderer reload recovery', () => {
|
||||
const rendererRoot = path.dirname(
|
||||
path.resolve(__dirname, '..', 'web', 'index.html')
|
||||
);
|
||||
const routedUrl = pathToFileURL(
|
||||
path.join(rendererRoot, 'workspace', 'sources')
|
||||
).href;
|
||||
|
||||
function fireWebContentsEvent(
|
||||
win: MockMainWindow,
|
||||
eventName: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
const handlers = win.webContents.on.mock.calls
|
||||
.filter(([name]) => name === eventName)
|
||||
.map(([, handler]) => handler);
|
||||
|
||||
expect(handlers).toHaveLength(1);
|
||||
handlers[0](...args);
|
||||
}
|
||||
|
||||
function createPackagedWindow(): MockMainWindow {
|
||||
process.env.ELECTRON_IS_DEV = '0';
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
getAppInternals().onReady();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
it('re-loads the packaged index with the route a failed file:// reload named', () => {
|
||||
const mainWindow = createPackagedWindow();
|
||||
|
||||
fireWebContentsEvent(
|
||||
mainWindow,
|
||||
'did-fail-load',
|
||||
{},
|
||||
-6,
|
||||
'ERR_FILE_NOT_FOUND',
|
||||
routedUrl,
|
||||
true
|
||||
);
|
||||
// Deferred to the error page's dom-ready, or the new document
|
||||
// never paints.
|
||||
expect(mainWindow.loadFile).not.toHaveBeenCalled();
|
||||
fireWebContentsEvent(mainWindow, 'dom-ready');
|
||||
|
||||
expect(mainWindow.loadFile).toHaveBeenCalledWith(
|
||||
path.join(rendererRoot, 'index.html'),
|
||||
{ query: { restoreRoute: 'workspace/sources' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a renderer-initiated reload of a routed URL to the index instead of cancelling it', () => {
|
||||
const mainWindow = createPackagedWindow();
|
||||
const event = { preventDefault: jest.fn() };
|
||||
|
||||
fireWebContentsEvent(mainWindow, 'will-navigate', event, routedUrl);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(mainWindow.loadFile).toHaveBeenCalledWith(
|
||||
path.join(rendererRoot, 'index.html'),
|
||||
{ query: { restoreRoute: 'workspace/sources' } }
|
||||
);
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still opens external URLs in the browser and blocks other navigations', () => {
|
||||
const mainWindow = createPackagedWindow();
|
||||
const external = { preventDefault: jest.fn() };
|
||||
const foreignFile = { preventDefault: jest.fn() };
|
||||
|
||||
fireWebContentsEvent(
|
||||
mainWindow,
|
||||
'will-navigate',
|
||||
external,
|
||||
'https://example.com/'
|
||||
);
|
||||
fireWebContentsEvent(
|
||||
mainWindow,
|
||||
'will-navigate',
|
||||
foreignFile,
|
||||
pathToFileURL(path.join(rendererRoot, '..', 'other')).href
|
||||
);
|
||||
|
||||
expect(external.preventDefault).toHaveBeenCalled();
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'https://example.com/'
|
||||
);
|
||||
expect(foreignFile.preventDefault).toHaveBeenCalled();
|
||||
expect(mainWindow.loadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the main window immediately when Electron is already ready', () => {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
|
||||
@@ -18,6 +18,15 @@ import {
|
||||
WINDOW_BOUNDS,
|
||||
} from './services/store.service';
|
||||
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
|
||||
import {
|
||||
attachZoomLevelPersistence,
|
||||
persistZoomLevel,
|
||||
} from './services/window-zoom-level';
|
||||
import {
|
||||
attachRendererReloadFallback,
|
||||
resolveRoutedRendererUrl,
|
||||
restoreRendererRoute,
|
||||
} from './services/renderer-reload-fallback';
|
||||
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
|
||||
import {
|
||||
FULLSCREEN_LAUNCH_SWITCH,
|
||||
@@ -354,6 +363,20 @@ export default class App {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// A renderer-initiated reload (`location.reload()`, e.g. the
|
||||
// settings unsaved-changes guard) on an in-app route arrives here as
|
||||
// a routed file:// URL with no file behind it. Send it straight to
|
||||
// the packaged index with that route instead of cancelling it.
|
||||
if (!App.isDevelopmentMode() && App.mainWindow) {
|
||||
const rendererIndexPath = getPackagedRendererIndexPath();
|
||||
const route = resolveRoutedRendererUrl(url, rendererIndexPath);
|
||||
|
||||
if (route !== null) {
|
||||
restoreRendererRoute(App.mainWindow, rendererIndexPath, route);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternalBrowserUrl(url)) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
@@ -450,6 +473,18 @@ export default class App {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the state restored on the next launch: window bounds and the
|
||||
* zoom level. Runs from both the window 'close' and app 'before-quit'
|
||||
* handlers, since either can be the last to run before the process ends.
|
||||
* The zoom half is a no-op until the window's preload took ownership of
|
||||
* the level (`services/window-zoom-level.ts`).
|
||||
*/
|
||||
private static persistWindowState(win: Electron.BrowserWindow): void {
|
||||
store.set(WINDOW_BOUNDS, win.getNormalBounds());
|
||||
persistZoomLevel(win);
|
||||
}
|
||||
|
||||
private static initMainWindow() {
|
||||
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
|
||||
const width = Math.min(1280, workAreaSize.width || 1280);
|
||||
@@ -539,6 +574,18 @@ export default class App {
|
||||
App.handleRendererNavigation
|
||||
);
|
||||
|
||||
// The preload restores the zoom level on every document load; the
|
||||
// main process only saves it back before a reload drops it.
|
||||
attachZoomLevelPersistence(App.mainWindow);
|
||||
|
||||
// A reload on an in-app route asks file:// for a path that does not
|
||||
// exist; re-load the packaged index with that route instead of
|
||||
// leaving Chromium's error page (see renderer-reload-fallback.ts).
|
||||
attachRendererReloadFallback(
|
||||
App.mainWindow,
|
||||
getPackagedRendererIndexPath()
|
||||
);
|
||||
|
||||
// Emitted when the window is closed.
|
||||
App.mainWindow.on('closed', () => {
|
||||
// Dereference the window object, usually you would store windows
|
||||
@@ -551,7 +598,7 @@ export default class App {
|
||||
|
||||
App.mainWindow.on('close', () => {
|
||||
if (App.mainWindow) {
|
||||
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
|
||||
App.persistWindowState(App.mainWindow);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -653,8 +700,7 @@ export default class App {
|
||||
}
|
||||
App.application.on('activate', App.onActivate); // App is activated
|
||||
App.application.on('before-quit', () => {
|
||||
if (App.mainWindow)
|
||||
store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds());
|
||||
if (App.mainWindow) App.persistWindowState(App.mainWindow);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
buildContentTitleFtsMatchQuery,
|
||||
buildCompoundFtsMatchQuery,
|
||||
buildCompoundLikePatterns,
|
||||
buildGlobPrefixPatterns,
|
||||
buildLikePatterns,
|
||||
buildM3uPayloadCompoundPatterns,
|
||||
getCompoundResidualTokenGroups,
|
||||
getCompoundSearchWords,
|
||||
getSearchWordPlans,
|
||||
getSqlSearchTokenGroups,
|
||||
getCompoundSearchWords,
|
||||
scoreSearchTextMatch,
|
||||
shouldUseContentTitlePrefixIndex,
|
||||
} from './content-search.util';
|
||||
@@ -122,6 +126,84 @@ describe('content-search.util', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Turkish case folding (issue #609)', () => {
|
||||
// Upper and lower case forms of the same query must build the same
|
||||
// search and match the same titles. The folding is locale-invariant,
|
||||
// so these hold under a Turkish OS locale too, where toLocaleLowerCase
|
||||
// would otherwise send "İ" and "I" to different letters.
|
||||
const pairs: ReadonlyArray<readonly [string, string]> = [
|
||||
['İnş', 'inş'],
|
||||
['Ünlü', 'ünlü'],
|
||||
['Çan', 'çan'],
|
||||
['Şan', 'şan'],
|
||||
];
|
||||
|
||||
it.each(pairs)(
|
||||
'builds the same search for %s and %s',
|
||||
(upper, lower) => {
|
||||
expect(getSqlSearchTokenGroups(upper)).toEqual(
|
||||
getSqlSearchTokenGroups(lower)
|
||||
);
|
||||
expect(buildContentTitleFtsMatchQuery(upper)).toBe(
|
||||
buildContentTitleFtsMatchQuery(lower)
|
||||
);
|
||||
expect(shouldUseContentTitlePrefixIndex(upper)).toBe(
|
||||
shouldUseContentTitlePrefixIndex(lower)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the dotted capital I as one word, not "i" + "nş"', () => {
|
||||
// The combining dot from lower-casing "İ" must not split the word,
|
||||
// which used to flip it onto the short-token prefix index.
|
||||
expect(getSqlSearchTokenGroups('İnş')).toEqual([['inş', 'ins']]);
|
||||
expect(shouldUseContentTitlePrefixIndex('İnş')).toBe(false);
|
||||
});
|
||||
|
||||
it('scores the same title for the upper and lower case query', () => {
|
||||
const upper = scoreSearchTextMatch('İnşaat', 'İnş');
|
||||
const lower = scoreSearchTextMatch('İnşaat', 'inş');
|
||||
|
||||
expect(lower).not.toBeNull();
|
||||
expect(lower).toBe(upper);
|
||||
});
|
||||
|
||||
it('spells the dotted capital İ forms in LIKE and GLOB patterns', () => {
|
||||
// SQLite LIKE folds only ASCII and GLOB folds nothing, and the
|
||||
// locale-invariant toUpperCase() turns "inş" into "INŞ", which
|
||||
// never matches a stored "İnşaat". The Turkish forms are added
|
||||
// with an explicit 'tr' locale so they do not depend on the OS.
|
||||
expect(buildLikePatterns('inş')).toEqual(
|
||||
expect.arrayContaining(['%İNŞ%', '%İnş%', '%INŞ%', '%inş%'])
|
||||
);
|
||||
expect(buildLikePatterns('İnş', 'prefix')).toEqual(
|
||||
expect.arrayContaining(['İnş%', 'İNŞ%', 'inş%'])
|
||||
);
|
||||
expect(buildGlobPrefixPatterns('iş')).toEqual(
|
||||
expect.arrayContaining(['İş*', 'İŞ*', 'iş*', 'IŞ*'])
|
||||
);
|
||||
expect(buildCompoundLikePatterns('iş-tv')).toEqual(
|
||||
expect.arrayContaining(['%İş-tv%', '%İŞ-TV%'])
|
||||
);
|
||||
});
|
||||
|
||||
it('adds no Turkish forms to values without an i', () => {
|
||||
expect(buildLikePatterns('çan')).toEqual([
|
||||
'%çan%',
|
||||
'%ÇAN%',
|
||||
'%Çan%',
|
||||
'%can%',
|
||||
'%CAN%',
|
||||
'%Can%',
|
||||
]);
|
||||
expect(buildGlobPrefixPatterns('ма')).toEqual([
|
||||
'ма*',
|
||||
'МА*',
|
||||
'Ма*',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreSearchTextMatch', () => {
|
||||
it('matches compound words anywhere in the title (issue #1161)', () => {
|
||||
expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Runs the search helpers' output through a real SQLite instance with the
|
||||
* same trigram FTS tokenizer the content index uses, so the case-folding
|
||||
* contract is checked against SQLite's own LIKE/GLOB/MATCH semantics rather
|
||||
* than against a string assertion (issue #609). Each SQL arm of the global
|
||||
* and per-playlist search is reproduced from the same pattern builders the
|
||||
* operations consume; the AND/OR composition mirrors `content.operations.ts`.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
buildCompoundLikePatterns,
|
||||
buildContentTitleFtsMatchQuery,
|
||||
buildGlobPrefixPatterns,
|
||||
buildLikePatterns,
|
||||
buildM3uPayloadTextFieldPatterns,
|
||||
getCompoundSearchWords,
|
||||
getSqlSearchTokenGroups,
|
||||
isShortSearchTokenGroup,
|
||||
scoreSearchTextMatch,
|
||||
shouldUseContentTitleFts,
|
||||
shouldUseContentTitlePrefixIndex,
|
||||
} from './content-search.util';
|
||||
|
||||
const TITLES = [
|
||||
'İnşaat Kanalı',
|
||||
'inşaat dünyası',
|
||||
'İşte Benim Stilim',
|
||||
'Ünlü Şef',
|
||||
'Çanakkale',
|
||||
'Şan Ve Şeref',
|
||||
'Первый канал HD',
|
||||
'ПЕРВЫЙ КАНАЛ',
|
||||
'Ёжик ТВ',
|
||||
'Йога для всех',
|
||||
'Матч ТВ',
|
||||
'Ελλάδα Σήμερα',
|
||||
'Amélie',
|
||||
'US: iş-tv',
|
||||
];
|
||||
|
||||
type Arm = 'fts' | 'glob' | 'like' | 'm3u' | 'compound' | 'score';
|
||||
|
||||
function likeClauses(
|
||||
column: string,
|
||||
groups: string[][],
|
||||
build: (token: string, mode: 'contains' | 'prefix') => string[]
|
||||
): { where: string; params: string[] } {
|
||||
const clauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
groups.forEach((tokens, index) => {
|
||||
const mode =
|
||||
index === 0 && isShortSearchTokenGroup(tokens)
|
||||
? 'prefix'
|
||||
: 'contains';
|
||||
const patterns = tokens.flatMap((token) => build(token, mode));
|
||||
clauses.push(
|
||||
`(${patterns.map(() => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})`
|
||||
);
|
||||
params.push(...patterns);
|
||||
});
|
||||
return { where: clauses.join(' AND '), params };
|
||||
}
|
||||
|
||||
describe('content-search.util against SQLite', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeAll(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE c(title TEXT);
|
||||
CREATE VIRTUAL TABLE f USING fts5(
|
||||
title, content='c', content_rowid='rowid',
|
||||
tokenize='trigram remove_diacritics 1'
|
||||
);
|
||||
CREATE TABLE p(title TEXT, payload TEXT);
|
||||
`);
|
||||
const insert = db.prepare('INSERT INTO c(title) VALUES (?)');
|
||||
const insertPayload = db.prepare(
|
||||
'INSERT INTO p(title, payload) VALUES (?, ?)'
|
||||
);
|
||||
for (const title of TITLES) {
|
||||
insert.run(title);
|
||||
insertPayload.run(
|
||||
title,
|
||||
JSON.stringify({ items: [{ name: title, title }] })
|
||||
);
|
||||
}
|
||||
db.exec(`INSERT INTO f(f) VALUES ('rebuild')`);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function titles(sql: string, params: string[]): string[] {
|
||||
return db
|
||||
.prepare(sql)
|
||||
.all(...params)
|
||||
.map((row) => (row as { title: string }).title)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function run(query: string): Partial<Record<Arm, string[]>> {
|
||||
const out: Partial<Record<Arm, string[]>> = {};
|
||||
const groups = getSqlSearchTokenGroups(query);
|
||||
|
||||
if (shouldUseContentTitlePrefixIndex(query)) {
|
||||
const patterns = groups[0].flatMap((token) =>
|
||||
buildGlobPrefixPatterns(token)
|
||||
);
|
||||
out.glob = titles(
|
||||
`SELECT title FROM c WHERE ${patterns
|
||||
.map(() => 'title GLOB ?')
|
||||
.join(' OR ')}`,
|
||||
patterns
|
||||
);
|
||||
} else if (shouldUseContentTitleFts(query)) {
|
||||
out.fts = titles(
|
||||
`SELECT c.title FROM f INNER JOIN c ON c.rowid = f.rowid WHERE f MATCH ?`,
|
||||
[buildContentTitleFtsMatchQuery(query)]
|
||||
);
|
||||
}
|
||||
|
||||
const like = likeClauses('title', groups, buildLikePatterns);
|
||||
out.like = titles(
|
||||
`SELECT title FROM c WHERE ${like.where}`,
|
||||
like.params
|
||||
);
|
||||
|
||||
const m3u = likeClauses(
|
||||
'payload',
|
||||
groups,
|
||||
buildM3uPayloadTextFieldPatterns
|
||||
);
|
||||
out.m3u = titles(`SELECT title FROM p WHERE ${m3u.where}`, m3u.params);
|
||||
|
||||
// The compound arm of the prefix path: a punctuation-joined word is
|
||||
// looked up as an intact substring, because the prefix index only
|
||||
// sees titles starting with the short first token (issue #1161).
|
||||
const compoundWords = getCompoundSearchWords(query);
|
||||
if (compoundWords.length > 0) {
|
||||
const patterns = compoundWords.flatMap((word) =>
|
||||
buildCompoundLikePatterns(word)
|
||||
);
|
||||
out.compound = titles(
|
||||
`SELECT title FROM c WHERE ${patterns
|
||||
.map(() => `title LIKE ? ESCAPE '\\'`)
|
||||
.join(' OR ')}`,
|
||||
patterns
|
||||
);
|
||||
}
|
||||
|
||||
out.score = TITLES.filter(
|
||||
(title) => scoreSearchTextMatch(title, query) !== null
|
||||
).sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['İnş', 'inş', ['İnşaat Kanalı', 'inşaat dünyası']],
|
||||
['İşt', 'işt', ['İşte Benim Stilim']],
|
||||
['İş', 'iş', ['İşte Benim Stilim']],
|
||||
['Ünl', 'ünl', ['Ünlü Şef']],
|
||||
['Çan', 'çan', ['Çanakkale']],
|
||||
['Şan', 'şan', ['Şan Ve Şeref']],
|
||||
])(
|
||||
'finds the same Turkish titles for %s and %s in every SQL arm',
|
||||
(upper, lower, expected) => {
|
||||
const upperResult = run(upper);
|
||||
const lowerResult = run(lower);
|
||||
|
||||
expect(lowerResult).toEqual(upperResult);
|
||||
for (const arm of Object.keys(lowerResult) as Arm[]) {
|
||||
expect(lowerResult[arm]).toEqual([...expected].sort());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['Первый', 'первый', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']],
|
||||
['ПЕРВ', 'перв', ['Первый канал HD', 'ПЕРВЫЙ КАНАЛ']],
|
||||
['Ёжик', 'ёжик', ['Ёжик ТВ']],
|
||||
['Йога', 'йога', ['Йога для всех']],
|
||||
['Ма', 'ма', ['Матч ТВ']],
|
||||
['ΕΛΛ', 'ελλ', ['Ελλάδα Σήμερα']],
|
||||
])(
|
||||
'keeps Cyrillic and Greek case pairs %s / %s equal and complete',
|
||||
(upper, lower, expected) => {
|
||||
const upperResult = run(upper);
|
||||
const lowerResult = run(lower);
|
||||
|
||||
expect(lowerResult).toEqual(upperResult);
|
||||
for (const arm of Object.keys(lowerResult) as Arm[]) {
|
||||
expect(lowerResult[arm]).toEqual([...expected].sort());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('reaches a lower-case compound title from the dotted capital İ spelling', () => {
|
||||
// "İş-TV" lower-cases to "i" + U+0307, which is not how the stored
|
||||
// "US: iş-tv" is spelled, and its short first token keeps the token
|
||||
// arm prefix-anchored — so the compound arm has to carry the
|
||||
// mark-free lower-case form.
|
||||
expect(run('İş-TV').compound).toEqual(['US: iş-tv']);
|
||||
expect(run('iş-tv').compound).toEqual(['US: iş-tv']);
|
||||
expect(run('İş-TV')).toEqual(run('iş-tv'));
|
||||
});
|
||||
|
||||
it('matches a decomposed query against the precomposed stored title', () => {
|
||||
// The query normalizer composes first, so "Ame" + U+0301 + "lie"
|
||||
// produces the same tokens as the precomposed spelling.
|
||||
expect(run('Ame\u0301lie').like).toEqual(run('Amélie').like);
|
||||
expect(run('Ame\u0301lie').score).toEqual(['Amélie']);
|
||||
});
|
||||
|
||||
it('keeps the diacritic-stripped fallback for Latin accents', () => {
|
||||
expect(run('Amélie').fts).toEqual(['Amélie']);
|
||||
expect(run('amelie').fts).toEqual(['Amélie']);
|
||||
expect(run('amelie').score).toEqual(['Amélie']);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,55 @@ export function escapeLikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Case spellings a stored title may use for `value`, for LIKE/GLOB patterns:
|
||||
* SQLite LIKE folds only ASCII and GLOB folds nothing, so every form has to
|
||||
* be spelled out. Besides lower/upper/title case, a value containing "i"
|
||||
* also gets the Turkish forms with the dotted capital İ (U+0130) — "inş"
|
||||
* becomes "İNŞ" / "İnş" — because the locale-invariant `toUpperCase()`
|
||||
* yields "INŞ", which never matches a title such as "İnşaat" (issue #609),
|
||||
* and conversely the mark-free lower-case form, which `toLowerCase()` alone
|
||||
* does not produce for a value already spelled with "İ".
|
||||
* The explicit `'tr'` locale is the point: it is fixed, not the OS locale.
|
||||
*/
|
||||
function getCaseVariants(value: string): string[] {
|
||||
const lower = value.toLowerCase();
|
||||
// Lower-casing "İ" leaves "i" + U+0307, so `lower` is not the spelling a
|
||||
// stored lower-case title uses; `folded` is. Without it a query typed as
|
||||
// "İş-TV" carries no pattern that matches a stored "US: iş-tv" — the
|
||||
// token arm is prefix-anchored on the short first token, and the
|
||||
// compound arm would only offer the diacritic-stripped "is-tv".
|
||||
const folded = lower.replace(/[\u0300-\u036f]/g, '');
|
||||
const variants = new Set<string>([
|
||||
value,
|
||||
lower,
|
||||
folded,
|
||||
value.toUpperCase(),
|
||||
value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(),
|
||||
]);
|
||||
|
||||
if (folded.includes('i')) {
|
||||
variants.add(folded.toLocaleUpperCase('tr'));
|
||||
variants.add(
|
||||
folded.charAt(0).toLocaleUpperCase('tr') + folded.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
// Search case folding is deliberately locale-invariant (toLowerCase, not
|
||||
// toLocaleLowerCase). Under a Turkish or Azeri locale toLocaleLowerCase maps
|
||||
// ASCII "I" to the dotless "ı" and folds the dotted "İ" its own way, so the
|
||||
// same title would be found or missed depending on the user's OS locale, and
|
||||
// the query would not line up with the SQLite FTS index, which folds
|
||||
// locale-invariantly. Issue #609: "İnş" and "inş" returned different results.
|
||||
export function normalizeSearchMatchText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -31,7 +74,20 @@ export function normalizeSearchMatchText(value: unknown): string {
|
||||
function normalizeSqlSearchText(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.toLocaleLowerCase()
|
||||
.toLowerCase()
|
||||
// Re-compose first so canonically equivalent spellings of the
|
||||
// query produce one token: a decomposed "e" + U+0301 would
|
||||
// otherwise lose its accent below and stop matching a stored
|
||||
// precomposed "é". Same rule as `foldSearchText`.
|
||||
.normalize('NFC')
|
||||
// Drop the combining marks case folding leaves behind, before the
|
||||
// split below can read them as word separators: "İ" (U+0130)
|
||||
// lower-cases to "i" plus a combining dot above (U+0307), which
|
||||
// otherwise split "İnş" into "i" and "nş" while "inş" stayed one
|
||||
// token, routing the two differently (issue #609). Same
|
||||
// U+0300-U+036F range normalizeSearchMatchText strips; precomposed
|
||||
// letters (ç, ş, ü, é) are outside it and left untouched.
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -75,16 +131,9 @@ export function buildLikePatterns(
|
||||
continue;
|
||||
}
|
||||
|
||||
const titleCase =
|
||||
trimmedValue.length > 0
|
||||
? trimmedValue.charAt(0).toLocaleUpperCase() +
|
||||
trimmedValue.slice(1).toLocaleLowerCase()
|
||||
: trimmedValue;
|
||||
|
||||
variants.add(trimmedValue);
|
||||
variants.add(trimmedValue.toLocaleLowerCase());
|
||||
variants.add(trimmedValue.toLocaleUpperCase());
|
||||
variants.add(titleCase);
|
||||
for (const variant of getCaseVariants(trimmedValue)) {
|
||||
variants.add(variant);
|
||||
}
|
||||
}
|
||||
|
||||
return [...variants].map((value) => {
|
||||
@@ -97,13 +146,9 @@ export function buildGlobPrefixPatterns(token: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of [token, ...getSqlSearchTokenVariants(token)]) {
|
||||
variants.add(value);
|
||||
variants.add(value.toLocaleLowerCase());
|
||||
variants.add(value.toLocaleUpperCase());
|
||||
variants.add(
|
||||
value.charAt(0).toLocaleUpperCase() +
|
||||
value.slice(1).toLocaleLowerCase()
|
||||
);
|
||||
for (const variant of getCaseVariants(value)) {
|
||||
variants.add(variant);
|
||||
}
|
||||
}
|
||||
|
||||
return [...variants].map((value) => `${value}*`);
|
||||
@@ -236,13 +281,9 @@ export function buildCompoundLikePatterns(word: string): string[] {
|
||||
const variants = new Set<string>();
|
||||
|
||||
for (const value of getCompoundWordVariants(word)) {
|
||||
variants.add(value);
|
||||
variants.add(value.toLocaleLowerCase());
|
||||
variants.add(value.toLocaleUpperCase());
|
||||
variants.add(
|
||||
value.charAt(0).toLocaleUpperCase() +
|
||||
value.slice(1).toLocaleLowerCase()
|
||||
);
|
||||
for (const variant of getCaseVariants(value)) {
|
||||
variants.add(variant);
|
||||
}
|
||||
}
|
||||
|
||||
return [...variants].map((value) => `%${escapeLikePattern(value)}%`);
|
||||
@@ -261,7 +302,7 @@ export function buildCompoundFtsMatchQuery(searchTerm: string): string {
|
||||
const quotedVariants = [
|
||||
...new Set(
|
||||
getCompoundWordVariants(word).map((variant) =>
|
||||
variant.toLocaleLowerCase()
|
||||
variant.toLowerCase()
|
||||
)
|
||||
),
|
||||
].map((variant) => `"${variant.replace(/"/g, '""')}"`);
|
||||
|
||||
Loaded 100 of 732 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user