Merge remote-tracking branch 'origin/master' into claude/parental-control-feature-31dde2

# Conflicts:
#	apps/electron-backend/src/main.ts
This commit is contained in:
4gray committed 2026-09-26 23:04:22 +02:00
commit a030e1af2f
63 files changed
+4917 -262

No files matched your search

+5
View File
@@ -87,6 +87,11 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database
- **Display order**: The sidebar defaults to server order. Users can switch the
category panel to `A-Z` or `Z-A` from the sort menu next to category search.
- **Selection scrolling**: The panel centers the rendered selected row after
selection changes. Electron selects by local SQLite category ID; the row's
`data-category-id` can contain its provider ID. Those IDs are not
interchangeable when locating the scroll target, including after filtering
hidden categories or sorting.
- **All-hidden recovery**: Once the selected Xtream type is loaded, the manage
categories button remains available even if every visible category has been
hidden. The sidebar category list is filtered, but the dialog reads all
+188 -15
View File
@@ -4,8 +4,10 @@ IPTVnator measures performance through a small set of everyday user journeys.
Each journey has deterministic counters that are asserted exactly, and
wall-clock timings that are recorded as evidence. Counters are ratcheted in CI:
a committed baseline may only be lowered, and only with the measured output as
evidence. This document is the contract for that loop; `tools/performance/`
holds the scripts.
evidence. This document is the contract for that loop. The journey harness lives in
`apps/electron-backend-e2e/src/journeys` and
`apps/electron-backend-e2e/src/performance/journey-*.ts`; the ratchet scripts
live in `tools/performance/`.
## Journeys
@@ -16,9 +18,159 @@ holds the scripts.
| J3 `playback` | click on a channel | HTML5 `playing` event |
| J4 `search` | six-character query typed into global search | results list settled |
Only the J1 counter `renderer.initialBytes` is instrumented today. The other
journeys and counters follow the plan in `.plans/` and are added one thread at
a time; each thread names its journey and counter in the PR description.
J1 is instrumented today: `renderer.initialBytes` from the built output, and
the runtime counters of the launch benchmark below. J2 to J4 follow the plan
in `.plans/` and are added one thread at a time; each thread names its journey
and counter in the PR description.
## Running the journeys
```bash
pnpm run perf:journeys
```
The script runs the Nx target `electron-backend-e2e:journeys`, which builds the
`electron-performance` configuration of the Electron app and the renderer
first, starts the Xtream mock server on the dedicated loopback port
`127.0.0.1:3231` (override with `IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT`), and runs
`playwright.journeys.config.ts` with one worker. Each run writes one file:
```
dist/performance/journeys/<YYYYMMDDTHHMMSSZ>/summary.json
```
The file is never overwritten; a second run in the same second fails instead.
`IPTVNATOR_JOURNEY_MEASURED_ITERATIONS` lowers the five measured iterations
for a quick local check; the warm-up iteration always runs. Numbers from a
laptop are previews: the Linux CI runner is the canonical measurer for
baselines, as it is for `renderer.initialBytes`.
## J1 `launch`: launch to usable
The profile holds one M3U source and one Xtream portal, both served by the
Xtream mock (`/playlist.m3u` and `player_api.php` on the same origin). The
profile is seeded once per run through the app's own "Add playlist" dialogs,
then every iteration copies that seeded data directory into a fresh temporary
directory and spawns a fresh Electron process on it. One warm-up iteration is
recorded but excluded from the summary; five measured iterations follow. The
app lands on `/workspace/dashboard`, so the first card is a card of the
"Recent sources" rail; an `app-playlist-item` row on `/workspace/sources`
also ends the journey for profiles that disable the dashboard.
The journey ends at the first `MutationObserver` batch in which all of the
following hold: the location is below `/workspace`, `#initial-splash` is no
longer in the DOM, and a source card has a non-empty client rect. Counters are
frozen at that microtask checkpoint, so bridge calls and mutations issued
later in the same task are included and everything after it is not.
Three test-side pieces are injected; production code is not changed:
- `journey-renderer-gate.cjs` is loaded into the main process with `-r`, the
mechanism Playwright uses for its own loader. Playwright resolves
`electron.launch()` while the app is already creating its window, and
Electron reports no page until a navigation commits, so an init script
registered afterwards would race the first document. The gate makes the
first `loadFile` navigate to `about:blank` and holds the real load until
the test releases it. A 15 s safety timeout releases it on its own and the
iteration is then invalid.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
`about:blank` page, so it runs at the start of the real document. It
records that it ran while the document was still `loading` with zero
scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`.
- `journey-main-ipc-capture.ts` subscribes to the preload's renderer-API trace
channel (`IPTVNATOR_DEBUG_TRACE_EVENT`, enabled with
`IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate`, also before the
release. The record refuses an iteration whose gate timed out, saw a second
load, or released before the probe was in place.
### Counters
| Counter | Source |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsToFirstCard` | `start` trace events the preload emits for every bridge invocation (listener registrations `on*`/`remove*` excluded, as in `wrapElectronApi`). The renderer probe fires one sentinel `dbGetAppPlaylist('__iptvnator-journey-sentinel__')` at the terminal moment; renderer-to-main IPC is ordered, so events before the sentinel are the exact count. |
| `renderer.domMutationsToFirstCard` | `MutationRecord`s (not callback batches) from a `MutationObserver` on the document element with `childList`, `attributes`, `characterData` and `subtree`. When the init script runs before `<html>` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. |
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
Counters are exact: the summary carries the value shared by every measured
iteration. When iterations disagree, the summary reports the maximum and marks
the counter `stable: false` under `counterStability`; such a counter is not
promoted to a guardrail until it is deterministic.
Two counters from the plan are listed under `unavailable` with the reason
instead of being faked:
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
scripts, which sets `ngDevMode` to false, so Angular does not publish
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
terminal moment and the record refuses a build where the hook exists but was
not counted.
- `main.sqlStatementsBeforeReadyToShow`: SQL statements are only visible as
worker-thread trace lines on stdout, which Node forwards asynchronously, so
they cannot be ordered against `ready-to-show`. Plan item A2 adds a channel
that can be counted.
### Wall-clock
| Entry | Derivation |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spawnToDidFinishLoadMs.p50/.p90` | `performance.timeOrigin + loadEventEnd` of the navigation entry (the main frame's `load`, which is what `did-finish-load` reports) minus the test-side timestamp taken just before `electron.launch`. |
| `spawnToFirstCardMs.p50/.p90` | Terminal epoch of the renderer probe minus the same spawn timestamp. |
Percentiles use linear interpolation over the five measured iterations. The
spawn timestamp includes Playwright's own launch overhead and the gate's
`about:blank` detour: Playwright holds `app.whenReady()` until its CDP session
is attached, and the real document loads only after the probes are in place,
so absolute values are larger than a bare launch. They are comparable between runs of the same
harness, which is what the ratchet needs. The main process start
(`Date.now() - process.uptime()`) is recorded per iteration under
`evidence.epochs` for cross-checks.
### Summary schema
```json
{
"schemaVersion": 1,
"generatedAt": "2026-09-26T11:02:14.318Z",
"harness": {
"platform": "darwin",
"electron": "43.3.0",
"measuredIterations": 5,
"warmupIterations": 1
},
"journeys": {
"launch": {
"counters": { "renderer.ipcCallsToFirstCard": 12 },
"counterStability": {
"renderer.ipcCallsToFirstCard": {
"stable": true,
"values": [12, 12, 12, 12, 12]
}
},
"wallClock": {
"spawnToFirstCardMs.p50": 1234.5,
"spawnToFirstCardMs.p90": 1300.1
},
"unavailable": { "renderer.cdTicksToFirstCard": "reason" },
"iterations": [
{
"index": 0,
"warmup": true,
"pid": 1,
"counters": {},
"wallClock": {},
"evidence": {}
}
]
}
}
}
```
`journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are plain
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
`tools/performance/journey-baselines.json`. A J1 baseline is added once the
numbers are stable on the CI runner; until then the summary is evidence only.
## `renderer.initialBytes`
@@ -65,17 +217,17 @@ counter:
```json
{
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2739510,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1693,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2739510,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1693,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
}
}
```
@@ -144,3 +296,24 @@ trade-off, say so in the PR and let the maintainer decide.
the test file in `tools/performance/project.json`.
4. Validate the counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
## Adding a journey
1. Add `apps/electron-backend-e2e/src/journeys/<journey>.journey.ts`. Seed the
profile through the app's dialogs, spawn a fresh process per iteration
with `measureLaunchJourney` as the model, and drive the journey's start
action with Playwright.
2. Give the journey its own probe options (`cardSelector`, `routeFragment`,
terminal condition) or extend `journey-renderer-probe.ts` when the end
condition is not "an element became visible". Keep the probe
self-contained: Playwright serializes it with `toString()`.
3. Map the measurement to a `JourneyIterationRecord` in a
`<journey>-journey-record.ts` under `src/performance/`; name counters
`renderer.*` or `main.*`, and list counters you cannot measure under
`unavailable` with the reason.
4. Add the journey under `journeys.<id>` in the summary through
`summarizeJourneyIterations`; the schema needs no change.
5. Cover the probe with jsdom fixtures and the record and summary code with
`node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`).
6. Validate a counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
+7 -2
View File
@@ -436,8 +436,13 @@ Publishing the GitHub release is manual. That publication automatically
verifies its Snap assets and uploads them to `edge`; installed-Snap smoke and
candidate/stable promotion remain manual (see
`tools/packaging/validate-snap-release-boundary.mjs`). Keep the blog post a
draft during artifact verification, then publish it in a follow-up commit and
verify the website deployment.
draft during artifact verification. After the release is public and its assets
are verified, publish the blog and advance
`apps/website/released-version.json` to that published version in the same
follow-up commit. Run `WEBSITE_SKIP_RELEASE_FETCH=1 pnpm nx test website --skip-nx-cache`,
compare the generated download links with the public release assets, and
verify the website deployment. The fallback pin must never follow the
development/nightly version in the root `package.json`.
If a Store upload fails after publication, run `publish-snap.yaml` from
`master` with its `tag` input set to the existing public stable tag, for example
+15 -1
View File
@@ -135,6 +135,16 @@ After an E2E run, generate the semantic summary with:
pnpm run coverage:e2e:summary
```
CI runs the Electron suite as three Playwright shards per OS
(`--shard=<n>/3`, split by spec file because the suite is sequential). Each
shard uploads `playwright-report-electron-<os>-<n>`; the follow-up
`Electron E2E summary` job downloads the shards of each OS into their own
directory and runs the summary per OS with `--input=<directory>` and
`--output-dir=coverage/e2e/<os>`. A directory input merges every
`results.json` beneath it and fails when a shard is missing or duplicated, so
the summary never reports a partial run as complete. Tests for that merge live
in `tools/coverage/e2e-shard-reports.test.mjs` (`pnpm run coverage:tools:test`).
For local investigation only, Chromium browser V8 coverage can be explored with:
```bash
@@ -160,6 +170,7 @@ pnpm nx build web
pnpm run perf:initial-bytes # breakdown only
pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline
pnpm nx test performance-tools
pnpm run perf:journeys # J1 launch benchmark, writes dist/performance/journeys/<timestamp>/summary.json
```
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
@@ -168,7 +179,10 @@ bytes on the initial path (the J1 counter `renderer.initialBytes`).
`tools/performance/journey-baselines.json`; baselines only move down. CI runs
the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that
target `master` and for `master` pushes (dispatch it with
`gh workflow run ci.yml --ref <branch>` for a stacked branch). The contract, what counts and how to add a counter are in the
`gh workflow run ci.yml --ref <branch>` for a stacked branch). `perf:journeys` builds the `electron-performance` configuration and runs the
J1 launch benchmark against the Xtream mock; its probe specs run with
`pnpm nx run electron-backend-e2e:test-performance-harness`. The contract, what
counts and how to add a counter or a journey are in the
[performance journeys](performance-journeys.md) document.
## Logging
+22 -2
View File
@@ -32,6 +32,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`
- Settings, portal request/response, and trace payloads must use
`@iptvnator/shared/logging` or the redacting portal logger before reaching
@@ -93,8 +94,27 @@ classifying a zero rendered-frame signal as an infrastructure flake.
## Main-process ownership
The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database,
registers events and creates the main window. The preload is
The process entry is `apps/electron-backend/src/main.entry.ts` (built to
`dist/apps/electron-backend/main.js`): it enables the V8 compile cache under
`userData/v8-compile-cache` and then requires the application bundle,
`main.app.js`, built from `apps/electron-backend/src/main.ts`. Before the window
loads, `main.ts` registers only what the renderer can call before its first paint
(window state, the close guard, playlist-open requests, request-header shims);
everything else, including the database, portal, EPG, download, player,
remote-control and update IPC, lives in
`apps/electron-backend/src/app/startup/deferred-events.ts`, built as the
`deferred-events.js` chunk and loaded inside the window's `did-start-loading`
listener. That import and its registrations finish within the same task, so no
renderer `invoke` can find a missing handler (`app/startup/deferred-bootstrap.ts`
holds the scheduler and its test); the startup trace reports it as
`deferred-events:start` and `deferred-events:done`. The cache is
disposable; `IPTVNATOR_DISABLE_COMPILE_CACHE=1` turns it off and
`IPTVNATOR_COMPILE_CACHE_DIR` relocates it (E2E runs keep it inside
`IPTVNATOR_E2E_DATA_DIR`). nx-electron packages the backend through an
allowlist, so `apps/electron-backend/project.json` lists `main.app.js` and
`deferred-events.js` under the `files` option of the `package` and `make`
targets, and `verify:package-layout` fails when any of the entry files is missing
from `app.asar`. The preload is
`apps/electron-backend/src/app/api/main.preload.ts`, with handlers under
`apps/electron-backend/src/app/events/`. The window follows the saved startup mode
(normal/maximized/fullscreen); `--fullscreen` overrides a single launch. Use
+1 -1
View File
@@ -14,7 +14,7 @@ are not prerequisites for reading repository contracts.
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters and the CI ratchet; `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |