mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-player-controls-2705f1
# Conflicts: # libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts
This commit is contained in:
commit
8661b6e467
494 files changed
+24827
-1724
No files matched your search
@@ -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
|
||||
@@ -144,6 +149,18 @@ Otherwise the app may still be using an older
|
||||
`dist/apps/electron-backend/workers/database.worker.js` bundle even though the
|
||||
TypeScript source has already been updated.
|
||||
|
||||
### Parental Lock Toggles
|
||||
|
||||
While the parental lock is enabled (`Settings → Parental lock`), the same
|
||||
dialog renders a lock toggle per row and the whole dialog opens only after the
|
||||
PIN. Locks are a separate concept from `hidden`: they live in the renderer's
|
||||
lock store keyed by provider category id and are mirrored into
|
||||
`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them
|
||||
the same way it keeps `hidden`. The dialog is the one entry point on every
|
||||
portal type (Stalker's "Manage categories" button opens its lock-only
|
||||
dialog); a right-click on a category row offers a single-row Lock/Unlock
|
||||
through the same lock store. Contract: [parental lock](parental-lock.md).
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
|
||||
@@ -232,6 +232,53 @@ sub-entrypoints are `@iptvnator/shared/interfaces/ipc-commands` and
|
||||
`@iptvnator/shared/interfaces` barrel pulls in `ngx-indexed-db`, which the
|
||||
preload bundle must not carry, so the preload only type-imports the barrel
|
||||
and value-imports those two dependency-free modules directly.
|
||||
`@iptvnator/workspace/shell/util/settings-search` exists for the opposite
|
||||
reason: the main `workspace-shell-util` barrel is imported eagerly, and the
|
||||
settings search index must stay in the lazy settings and shell chunks.
|
||||
`@iptvnator/workspace/shell/util/settings-context` keeps
|
||||
`SettingsContextService`, which only the lazy settings page and settings
|
||||
context panel use, out of that barrel for the same reason, and
|
||||
`@iptvnator/services/playlist-backup` keeps `PlaylistBackupService` (about
|
||||
22 KB, used only by the lazy settings page) out of the `@iptvnator/services`
|
||||
barrel.
|
||||
|
||||
The web app's root shell (`app.component.ts`, `app.config.ts` and the services
|
||||
they construct) is on the renderer's initial path, where a barrel costs its
|
||||
whole library: esbuild keeps every Angular component module a barrel
|
||||
re-exports, because their static definitions count as side effects (so do an
|
||||
`@Injectable` service's and a pipe's). Importing
|
||||
`WindowControlsComponent` and `DialogService` from `@iptvnator/ui/components`
|
||||
once put the channel lists, EPG views, `@angular/forms`, `date-fns` and the
|
||||
Stalker data layer into `main.js`. The root shell therefore uses file-level
|
||||
entries: `@iptvnator/ui/components/window-controls`,
|
||||
`@iptvnator/ui/components/confirm-dialog`,
|
||||
`@iptvnator/playlist/shared/ui/stalker-connection-editor`,
|
||||
`@iptvnator/playlist/shared/util/playlist-file-import` (the barrel would add
|
||||
`PlaylistContextFacade`) and `@iptvnator/pipes/date-format` (the barrel would
|
||||
add `SafePipe`; the eager EPG progress panel uses it too), like the existing
|
||||
`@iptvnator/ui/epg/progress-panel`, and loads anything used only on demand
|
||||
through a local file it imports dynamically (the Stalker connection editor,
|
||||
the release-notes dialog, the external-player info dialog, the parental-lock
|
||||
PIN dialog and the Stalker step of the parental-lock enforcement, which runs
|
||||
only while a Stalker route is open). A local file,
|
||||
not the library alias, is the dynamic-import target because
|
||||
`@nx/enforce-module-boundaries` forbids static imports of a library the same
|
||||
project also loads dynamically.
|
||||
|
||||
A barrel the initial path imports also costs code it never runs itself:
|
||||
code splitting places a module in the chunk shared by every entry point
|
||||
that reaches it, so a helper that only lazy routes use, but that an eager
|
||||
file can reach through a barrel's re-export, lands in an initial chunk even
|
||||
though tree shaking would drop it from a single bundle. The eager Xtream
|
||||
data layer and root shell therefore import `@iptvnator/portal/shared/util/logger`
|
||||
and `@iptvnator/portal/shared/util/tokens` (the portal DI tokens), not the
|
||||
`@iptvnator/portal/shared/util` barrel, whose navigation, keyboard-shortcut
|
||||
and download helpers (about 45 KB) belong to the lazy portal routes. A
|
||||
type-only import of a barrel uses `import type`, so it can never keep the
|
||||
barrel reachable. `renderer.initialBytes` in
|
||||
[performance journeys](performance-journeys.md) guards the result; to see why a
|
||||
module is eager, build with `pnpm nx build web --stats-json` and follow the
|
||||
static imports in `dist/apps/web/stats.json` from `apps/web/src/main.ts`.
|
||||
|
||||
For a buildable library that has a local `package.json`, its `name` must match
|
||||
the scoped alias. Nx uses that package name when rewriting buildable dependency
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
# Parental Lock
|
||||
|
||||
Issue #285. A PIN-protected lock for individual categories: Xtream
|
||||
categories, Stalker genres and M3U `group-title`s. While the lock is active,
|
||||
everything in a locked category is withheld from the app; entering the PIN
|
||||
shows it again until the app locks itself.
|
||||
|
||||
This document is the contract. Phase 1 (this document's scope) covers the
|
||||
portals' own surfaces; the aggregate surfaces listed under "Not yet covered"
|
||||
follow in a second PR.
|
||||
|
||||
## Product rules
|
||||
|
||||
- Off by default. Enabling asks for a new PIN (4–8 digits); disabling and
|
||||
changing the PIN always verify the current PIN against the stored hash,
|
||||
even while the session is unlocked (`verifyCurrentPin()`, never the
|
||||
`requestUnlock()` short-cut) — a parent leaving the app unlocked must not
|
||||
leave the lock removable. Disabling keeps the locks for a later re-enable.
|
||||
- The unit of locking is the category. Nothing is blurred or greyed: a
|
||||
withheld category and its rows are absent. The only trace is one
|
||||
"N locked · Enter PIN to show" row at the bottom of a portal's category
|
||||
rail, which opens the PIN prompt. In fail-closed mode (lock store
|
||||
unreadable) the row shows without a count, since which categories are
|
||||
locked is unknown while every one of them is withheld.
|
||||
- The unlock lives in memory only. The app locks again on every restart, on
|
||||
"Lock now" (header button, command palette, settings), and after
|
||||
`Settings.parentalLockRelockMinutes` minutes without user interaction
|
||||
(default 15; `0` = only on restart). The idle timer follows the UNLOCKED
|
||||
transition: armed the moment a session is unlocked — including the session
|
||||
that just enabled the feature — and disarmed on lock. Active playback of a built-in web
|
||||
player counts as interaction, so a film never locks half way — video
|
||||
through the keep-awake tracker, and audio (the radio player) read from
|
||||
the playing `<audio>` elements, since the keep-awake service ignores
|
||||
them.
|
||||
- This is a child lock, not a security boundary: the PIN hash and the lock
|
||||
store sit in user-readable app data. The UI says so. There is no PIN
|
||||
recovery; resetting the app data is the way out.
|
||||
|
||||
## Storage
|
||||
|
||||
| What | Where (Electron) | Where (PWA) |
|
||||
| ------------------ | --------------------------------------------------------------------------------- | ------------------------- |
|
||||
| PIN hash | `app_state` key `parental-lock:pin` | localStorage, same key |
|
||||
| Lock store | `app_state` key `parental-lock:locks` | localStorage, same key |
|
||||
| Feature switch | `Settings.parentalLockEnabled`, mirrored to electron-conf `PARENTAL_LOCK_ENABLED` | `Settings` |
|
||||
| Relock timeout | `Settings.parentalLockRelockMinutes` | same |
|
||||
| Xtream query index | `categories.locked` column | none (filtered in memory) |
|
||||
|
||||
The PIN is hashed with PBKDF2-SHA256 through WebCrypto
|
||||
(`parental-lock-pin.util.ts`, format `v1$<iterations>$<salt>$<hash>`), so
|
||||
Electron and PWA share one implementation. The hash is deliberately not part
|
||||
of `Settings`: settings are logged, backed up and mirrored to the main
|
||||
process. A hash that could not be READ is not an absent PIN:
|
||||
`ParentalLockStorageService.readPinHash()` reports the failure as `null`
|
||||
(an absent PIN is `{hash: null}`), the session then stays locked (`enabled`
|
||||
treats an unreadable PIN as set while the switch is unknown) and every
|
||||
PIN-protected step — unlock, change PIN, disable — re-reads it first, so
|
||||
storage answering later is enough to recover without a restart.
|
||||
|
||||
The lock store (`ParentalLockStore` in `parental-lock.util.ts`) is keyed by
|
||||
playlist id and holds, per playlist, Xtream `{categoryType, xtreamId}`
|
||||
pairs, Stalker `{categoryType, categoryId}` pairs and M3U group titles. It
|
||||
is the source of truth for all three portal types. Xtream additionally keeps
|
||||
`categories.locked` as a SQLite index derived from it: `DB_SET_CATEGORY_LOCKS`
|
||||
re-stamps one playlist/type from a provider-id list (idempotent, so the store
|
||||
can be replayed after a refresh or a backup restore), and `DB_SAVE_CATEGORIES`
|
||||
takes `lockedCategoryXtreamIds` so a refresh recreates the rows already
|
||||
stamped. Locks are keyed by provider ids, never by SQLite row ids, because a
|
||||
refresh deletes and re-inserts the rows.
|
||||
|
||||
## Enforcement — two layers, both default-closed
|
||||
|
||||
1. **SQLite worker (Electron).** `apps/electron-backend/src/app/database/parental-lock-state.ts`
|
||||
holds one process-wide flag. While active, every content-returning read
|
||||
appends "category is not locked": `getCategories`, `getContent`,
|
||||
`searchContent`, the three `globalSearch` candidate selectors,
|
||||
`getGlobalRecentlyAddedByType`, `DB_MATCH_TITLES` and the multi-source
|
||||
discovery queries. A surface added later is therefore withheld by
|
||||
default. The flag is seeded through `workerData.parentalLockActive` when
|
||||
the worker is (re)started and flipped by the `parental-lock` control
|
||||
message on the request port (ordered with the requests, so a read posted
|
||||
after it observes the new state). `getAllCategories` is exempt: it feeds
|
||||
the management dialog, which itself sits behind the PIN.
|
||||
2. **Renderer `ParentalLockService`** (`libs/services/src/lib/parental-lock/`).
|
||||
Signals `enabled`, `unlocked`, `active` (= enabled && !unlocked),
|
||||
`hasPin`, `version`; `requestUnlock()` (one shared prompt for concurrent
|
||||
callers), `lock()`, `setupPin()`, `changePin()`, `disable()`, the lock
|
||||
store API (`setXtreamLocks`, `setStalkerLocks`, `setM3uLocks`,
|
||||
`replacePlaylistLocks`, `lockedXtreamIds`, …) and the `is*Locked`
|
||||
predicates, which answer `true` only while `active`. The PIN dialog is
|
||||
reached through the `PARENTAL_LOCK_PROMPT` token, provided by the app
|
||||
(`AppParentalLockPromptService` → `ParentalLockPinDialogComponent` in
|
||||
`libs/ui/components`), so the data-access lib stays free of UI.
|
||||
Five wrong PINs pause the prompt for 30 seconds. The count and the
|
||||
pause live in the service (`createParentalLockPinThrottle`, handed to
|
||||
every unlock prompt), so dismissing the dialog and opening it again
|
||||
does not reset them.
|
||||
|
||||
The renderer reports `active` to the main process over
|
||||
`PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`),
|
||||
which forwards it to the worker. `SETTINGS_UPDATE` only persists the
|
||||
`parentalLockEnabled` mirror; it never derives the live state from a settings
|
||||
save (every save carries the flag unchanged and would re-lock the worker
|
||||
under a renderer that shows "unlocked"). The one exception is a switch-off,
|
||||
which releases the worker at once. `requestUnlock()` awaits `initialize()`
|
||||
before it can answer "not active", so a slow settings load cannot open a
|
||||
gate. Like the playback keep-awake vote, the
|
||||
renderer's word does not outlive its page: on reload, main-frame navigation or
|
||||
a dead render process the flag falls back to the mirrored
|
||||
`PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The
|
||||
renderer, in turn, reports nothing before its settings have loaded, so it can
|
||||
never send a spurious "unlocked" while the main process still holds the
|
||||
locked default. Unreadable settings fail the same way: `SettingsStore`
|
||||
serves defaults and records `storageFailure = 'load'`, and the switch is
|
||||
then unknown rather than off — a stored PIN stands in for it (`enabled`
|
||||
follows `hasPin`, so the session starts locked and one PIN entry unlocks
|
||||
it), and without a PIN the renderer does not report at all, leaving the
|
||||
main process on its mirrored default instead of announcing "unlocked" on
|
||||
the strength of default settings. The lock store itself fails closed the
|
||||
same way: `ParentalLockStorageService.readLocks()` reports a failed read as
|
||||
`null` (distinct from an absent store, `{}`; Electron reads through
|
||||
`DatabaseService.readAppState`, which keeps a rejected IPC apart from a
|
||||
missing key, and a stored payload that does not parse or does not have the
|
||||
shape `writeLocks` produces — `isWellFormedParentalLockStore`, all three
|
||||
lists present, down to the nested entries — is a failed read too — corruption never becomes an empty
|
||||
store), and while the lock is active with the store unreadable
|
||||
`ParentalLockService.withholdsEverything` is true — every `is*Locked`
|
||||
predicate answers true and the set-based filters (PWA Xtream, Stalker
|
||||
content and search, the M3U channel list) receive
|
||||
`ALL_CATEGORIES_WITHHELD`, and `ElectronXtreamDataSource` serves no
|
||||
categories, content, cached categories/content (the warm-route hydration
|
||||
path) or search hits either, since its SQLite index may still
|
||||
carry a stale stamp — under which a row WITHOUT a genre is withheld
|
||||
too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre"
|
||||
must not be the one row a withheld catalog still shows — so the whole
|
||||
catalog is withheld until the PIN
|
||||
is entered or the store reads again (`requestUnlock` and every lock write
|
||||
retry the read first, and a write is refused while it still fails, since it
|
||||
would be built on an empty in-memory store and wipe the persisted locks).
|
||||
The same fail-closed mode applies while locked when Electron reads Xtream
|
||||
through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge
|
||||
lacks the worker filter (`supportsParentalLockSqliteFilter`:
|
||||
`setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older
|
||||
preload): the worker would never learn the lock state, so it cannot
|
||||
withhold locked rows itself. The same holds while locked after the
|
||||
lock-state sync itself was rejected (`ParentalLockWorkerSync`), until a
|
||||
later sync succeeds. The direct worker consumers
|
||||
(`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker
|
||||
nothing while `withholdsEverything` is true, in either case. The VOD
|
||||
multi-source host keys its discovery session to the lock version: a lock
|
||||
change drops the discovered sources, retires discoveries and switches in
|
||||
flight, and rediscovers through the worker's new lock state. Title-match
|
||||
results cached by the Actor/Discover routes and dashboard services are not
|
||||
yet retired on relock (tracked in #1723).
|
||||
The window before the initial read settles is treated the same way
|
||||
(`ParentalLockLockStore.readable` is false until then): settings can report
|
||||
the feature as on before the locks are known — and the workspace route's
|
||||
`settingsReady` resolver awaits `ParentalLockService.initialize()` next to
|
||||
the settings load, so no route or catalog activates before the PIN and the
|
||||
lock store are known (in the PWA the settings read can be the slower one).
|
||||
Every lock write re-reads a failed store BEFORE building its edit, so a
|
||||
recovered store is edited, never overwritten by an edit built on the empty
|
||||
fail-closed one. The feature switch itself is
|
||||
persisted through one guarded path (`persistEnabled`), and every
|
||||
parental-lock settings write first retries a failed startup settings read
|
||||
(`ensureSettingsReadable`) and is refused while settings stay unreadable —
|
||||
`updateSettings` writes the whole settings object, which after a failed
|
||||
read is the defaults and would replace the user's persisted preferences.
|
||||
Within that path `updateSettings`
|
||||
patches memory before it writes, so a failed write is undone in memory and
|
||||
`setupPin`/`disable` report false (whether to persist is decided from the
|
||||
settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin`
|
||||
while the switch is unknown); the Electron mirror write is awaited
|
||||
next, and a mirror that cannot be written undoes the settings write the
|
||||
same way — the toggle never shows a state the next launch will not have,
|
||||
on either side. The undo restores the value read AFTER the settings retry
|
||||
(not the hard-coded inverse, which a recovered read may already hold). The
|
||||
Settings switch itself only requests the change: it snaps back to the
|
||||
saved state at once and follows `enabled()` when the PIN action succeeds,
|
||||
so a cancelled or refused PIN leaves it showing the real state.
|
||||
|
||||
### In-memory catalogs
|
||||
|
||||
- **Xtream (Electron):** the store reads categories and streams through the
|
||||
worker, so they arrive filtered. On `version` changes
|
||||
`ParentalLockEnforcementService` (`apps/web/src/app/services/`) calls
|
||||
`XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the
|
||||
selected category vanished, clears the selection and navigates to the
|
||||
section root. The selected ITEM is judged separately, on its own
|
||||
`category_id` against the reloaded category list: a detail opened from
|
||||
"All", recently added or search has no selected category to vanish
|
||||
with, so its row disappearing from a list is not enough — the item is
|
||||
cleared and the route returns to the section root while the (still
|
||||
readable) selected category stays. Within one apply the synchronous
|
||||
surfaces (the M3U active channel, the Stalker selection) are stepped off
|
||||
BEFORE the Xtream reloads are awaited, so a playing M3U channel never
|
||||
waits behind a slow portal read — the Xtream store stays populated after
|
||||
leaving that portal, so its reload runs on every apply. The M3U check
|
||||
also runs whenever the active channel changes while locked: numeric
|
||||
zapping, next/previous and remote commands select from the full channel
|
||||
list without any lock change following, so a channel of a locked group
|
||||
is reset as soon as it becomes active (numeric zapping also skips it). Applies run one
|
||||
at a time and each is
|
||||
abandoned once a newer `version` exists (the queued apply reads the latest
|
||||
state): `ElectronXtreamDataSource` shares in-flight category/content
|
||||
reads per playlist and type, and its share key carries the lock version,
|
||||
so an unlock refresh still running when "Lock now" arrives can never hand
|
||||
the relock its unfiltered rows. The apply also re-runs the stored
|
||||
in-portal search (`XtreamStore.refreshSearchResults`, the last
|
||||
`searchContent` call as issued) — `searchResults` is a separate array the
|
||||
search page renders directly and would otherwise keep locked titles until
|
||||
the query changes. A RELOCK fails closed at once rather than after the
|
||||
database answers — ahead of the serialized apply queue, so an earlier
|
||||
apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when
|
||||
the lock store already names its category (the pre-reload category list
|
||||
maps Electron's row id to the provider id) or when that list cannot place
|
||||
it at all (a manually hidden category opened through search), then
|
||||
`withholdCatalog()`
|
||||
empties every catalog list and `clearSearchResults()` the stored search
|
||||
(retiring a search still in flight, which was issued under the previous
|
||||
lock state) before the filtered reads refill them; both reloads take a publish guard
|
||||
answered before every state patch, so a read issued under an older lock
|
||||
version is dropped instead of published. The guard also turns false once
|
||||
another Xtream playlist is open (the store is a singleton), and the store
|
||||
itself refuses to publish a reload into a playlist it was not read for;
|
||||
the post-reload search refresh and selection checks are skipped then
|
||||
too. The post-reload checks of the
|
||||
selected category and item decide by the LOCK STORE through the
|
||||
unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by
|
||||
absence from the reloaded list, which also omits categories the user
|
||||
merely hid; rows that cannot be read fail closed. A lock change that overtakes
|
||||
the INITIAL hydration (the content is not initialized yet, so the reload
|
||||
has nothing to re-read) sets a deferred-reload flag instead — already
|
||||
when `withholdCatalog()` empties the lists, before the category reload is
|
||||
awaited: the
|
||||
hydration then publishes empty lists in place of the rows it read under
|
||||
the previous lock state (categories included), and the filtered reload
|
||||
of categories and content runs as soon as the hydration settles, on
|
||||
every path that marks the content initialized, under the publish guard
|
||||
of the request that deferred it.
|
||||
Both reloads fail closed: a category reload that
|
||||
rejects empties the three category lists, and a per-type content reload
|
||||
that rejects empties that type and sets it back to `idle` so the next
|
||||
visit loads it again (filtered) — rows read under the previous lock state
|
||||
are never kept. Live playback is stopped by the layout itself:
|
||||
`LiveStreamLayoutComponent` keeps the playing channel's provider category
|
||||
id (mapped from the SQLite row id on Electron — through the visible
|
||||
category list, else through the unfiltered rows, since search can play a
|
||||
channel of a HIDDEN category; until that lookup lands the id is unknown
|
||||
and a relock stops the channel, and a lookup that lands after a later
|
||||
playback — same provider id, other playlist — is discarded) and drops `activePlayback` on a `version`
|
||||
change that locks it, because the player is gated on that
|
||||
signal, not on the store selection — which an ordinary category switch
|
||||
also clears while the channel keeps playing.
|
||||
- **Stalker:** the same service checks the selected genre through
|
||||
`isStalkerCategoryLocked` and, independently, the selected item's own
|
||||
genre — `tv_genre_id` for live and radio rows, `category_id` for VOD and
|
||||
series, the rule the store's withheld filter applies — so an item opened
|
||||
from `*` (All) or search is withheld by its genre even though `*` itself
|
||||
can never be locked. A withheld item is cleared and the section root is
|
||||
navigated to; the category selection is reset only when the genre itself
|
||||
is locked. A stream still RESOLVING at relock time is retired too: the
|
||||
embedded player defers selecting the channel until resolution, so the
|
||||
cleared selection cannot retire it — the playback request carries the
|
||||
lock version it was issued under (`isStalkerPlaybackRequestLockCurrent`)
|
||||
and is dropped when the session relocked meanwhile (an unlock lets it
|
||||
complete). `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
|
||||
whenever the store selection is cleared: its template mounts the player
|
||||
only with a selection, and the held stream must not resurface with the
|
||||
next one.
|
||||
- **M3U groups rail:** `ChannelListContainerComponent` derives
|
||||
`withheldGroupCount` (groups of the playlist the active lock withholds)
|
||||
and the groups rail renders the same "N locked · Enter PIN to show" row
|
||||
as the portal category rail, so locked groups do not simply vanish —
|
||||
also when EVERY group is locked: the container keeps the groups view
|
||||
(`showChannelViews`) and the groups view keeps its rail instead of the
|
||||
generic empty state.
|
||||
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
|
||||
streams and search hits at read time; the same reloads apply. Provider
|
||||
category ids are compared in canonical numeric form (`"009"` matches the
|
||||
stored lock `9`), as the lock editor stores them with `Number`.
|
||||
- **Warm-cache detection (Electron):** the filtered category/content reads
|
||||
can be empty while the offline cache is complete (every category locked),
|
||||
so `ElectronXtreamDataSource` confirms an empty read with the unfiltered
|
||||
`hasXtreamCategories` / `hasXtreamContent` before refetching from the
|
||||
provider.
|
||||
- **Routes:** `parentalLockXtreamCategoryGuard(section)` on every Xtream
|
||||
`:categoryId` route (live, vod, series and their detail children) and
|
||||
`parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series
|
||||
`:categoryId` routes prompt for the PIN when a locked category is reached
|
||||
by URL — bookmark, typed address, stale link — and redirect to the section
|
||||
root on refusal. Detail routes also resolve the ITEM's own category through
|
||||
`getContentByXtreamId`, so a locked movie paired with an unlocked category
|
||||
id in the URL is still refused; on a cold PWA navigation the guard
|
||||
hydrates the session cache first (`getPlaylist` + `getContent`), and an
|
||||
item the catalog cannot place fails closed (PIN prompt) rather than
|
||||
passing as unlocked. Electron routes carry SQLite row ids, so
|
||||
the Xtream guard maps them through the unfiltered category read; Stalker
|
||||
routes already carry the genre id.
|
||||
- **Stalker withheld-row bookkeeping:** the paging logic that advances past
|
||||
a page made only of withheld rows keys those rows by
|
||||
`stalkerWithheldRowKey` (`id`, else `stream_id`/`movie_id`/`series_id`,
|
||||
else the row's `cmd`/`name`), never by a shared `''`, so a page of new
|
||||
locked rows is not mistaken for a stalled portal.
|
||||
- **Stalker search relock:** a lock change drops the withheld rows already
|
||||
on screen at once (`applyRelockToResults`, page 1 included) and closes an
|
||||
open detail of a now-withheld genre (in fail-closed mode also one
|
||||
without a genre), before the replacement page is awaited — otherwise
|
||||
they stay clickable while that request is pending.
|
||||
- **Stalker search staleness:** portal requests are not aborted, so a
|
||||
search page issued before a relock can finish after it, filtered with the
|
||||
pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the
|
||||
response on the parental lock version as well as term/type/page/portal,
|
||||
so such a page is dropped instead of repopulating the grid.
|
||||
- **Stalker search route** (`/workspace/stalker/:id/search`, no category
|
||||
in the URL): `StalkerSearchComponent` filters each portal page through the
|
||||
same withheld-genre predicate, carries `parentalLockVersion` in its
|
||||
resource params, judges paging progress on the raw page (a page made only
|
||||
of locked rows is not the end of the results), advances past a run of
|
||||
fully withheld pages by itself (the infinite scroll stops auto-filling
|
||||
after a few no-growth loads), closes an open detail whose genre is newly
|
||||
withheld and, on a lock flip past page 1, drops the withheld rows and
|
||||
restarts from page 1.
|
||||
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
|
||||
them, `getAllCategoriesForSelectedType` is the raw list for the lock
|
||||
dialog. `itvFullChannelList` and the content loader drop rows whose
|
||||
`tv_genre_id` / `category_id` is withheld, the content resource's params
|
||||
carry `parentalLockVersion` so lock/unlock re-fires it, and a stale
|
||||
response is discarded when the version moved. Paging is judged on the RAW
|
||||
page: withheld ids the list has not seen before count as progress (so a
|
||||
portal page made only of locked rows does not end the list), a page that
|
||||
is entirely withheld requests the next page by itself, and the VOD/series
|
||||
`totalCount` is reduced by the withheld ids seen so the grid stops asking
|
||||
once every visible row is in. A lock flip while the list sits past page 1
|
||||
drops the withheld rows on screen at once and restarts from page 1, so rows
|
||||
accumulated under the old lock state are never appended to. A selected
|
||||
withheld genre is cleared and the
|
||||
section root is navigated to.
|
||||
- **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList`
|
||||
(all views, favorites, recents, the fullscreen panel and numeric zapping
|
||||
read from it); locked groups are absent from the groups rail. A playing
|
||||
channel of a locked group is reset on lock.
|
||||
|
||||
## UI
|
||||
|
||||
- `Settings → Parental lock` (`/workspace/settings/parental`): enable
|
||||
toggle, state + Lock now / Unlock, relock select, Change PIN, the
|
||||
disclaimer. Everything applies immediately (it gates on the PIN), outside
|
||||
the shared dirty form; `createSettingsFromFormValue` carries both values
|
||||
from the current settings so Save cannot undo them.
|
||||
- One entry point per rail, the same on every portal type: the "Manage
|
||||
categories" button (`tune` icon; "Manage groups" on an M3U playlist).
|
||||
Xtream and M3U render lock toggles inside their existing hide/show
|
||||
dialogs while the feature is on; Stalker has no hide/show dialog, so its
|
||||
button — shown only while the feature is on — opens the lock-only
|
||||
`StalkerCategoryLockDialogComponent`, which also offers "Lock adult
|
||||
(18+)" for genres the portal flags `censored`. All three list the locked
|
||||
names and can rewrite the locks, so each opens only after
|
||||
`requestUnlock()` succeeds — and closes itself, discarding the draft, the
|
||||
moment `active` becomes true again (idle relock, Lock now), since the
|
||||
PIN gate covers only the opening. The M3U group dialog does this only
|
||||
while it carries lock toggles; the plain hide/show editor is not behind
|
||||
the PIN. The Xtream dialog loads its candidates through the
|
||||
capability-selected data source (`IXtreamDataSource.getAllCategories`,
|
||||
which the PWA source answers from its session cache or the API), so PWA
|
||||
users can set locks too; the hide/show checkboxes remain Electron-only.
|
||||
A deliberate non-choice: no dedicated lock button in the rail header
|
||||
(the header already carries search, sort and manage, and the lock is a
|
||||
category-management concern) and no keyword-based "adult" recognition
|
||||
for Xtream/M3U (provider naming is arbitrary; the only automation is the
|
||||
portal's own `censored` flag on Stalker).
|
||||
- Right-click accelerator: a `contextmenu` on a category row (Xtream and
|
||||
Stalker rail, `WorkspaceContextCategoryViewComponent`) or an M3U group
|
||||
row opens the shared `CategoryLockMenuComponent` (`libs/ui/components`)
|
||||
with one "Lock / Unlock category (group)" item, only while the feature
|
||||
is on. The portal rail persists through
|
||||
`WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the
|
||||
lock store, failure snackbar); the M3U rail emits the toggled group to
|
||||
`ChannelListContainerComponent`, which captures the playlist, asks for
|
||||
the PIN, and persists the edit only if that playlist is still open (two
|
||||
playlists can share a group name). The M3U management dialog is bound
|
||||
the same way: `GroupsViewComponent` captures its `playlistId` input
|
||||
before the PIN and drops the dialog's result once another playlist is
|
||||
open. Every bulk lock editor (Xtream and M3U management dialogs, the
|
||||
Stalker lock dialog) builds its draft only from a lock store that was
|
||||
read (`ensureLocksReadable`): a draft taken from the empty fail-closed
|
||||
snapshot would erase the real locks on Save if storage recovered in
|
||||
between. The M3U and Xtream dialogs then open without lock toggles; the
|
||||
Stalker dialog does not open. Every one of them closes on a relock, the
|
||||
M3U one also when it opened without toggles, since it still lists every
|
||||
group name. Bulk actions stay in the dialog, and while the session is locked
|
||||
a locked category is not in the rail, so unlocking always goes through
|
||||
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
|
||||
hands its lock list back to `ChannelListContainerComponent`, which
|
||||
awaits the write and reports a failed save in a snackbar (the dialog has
|
||||
closed by then). On Electron the `categories.locked` re-stamp
|
||||
(`setCategoryLocks`) clears and re-locks one playlist/type inside ONE
|
||||
transaction, so a failed restamp keeps the previous index instead of
|
||||
leaving every category unlocked. The relock timeout persists through the
|
||||
same undo-on-failure pattern as the switch (`setRelockMinutes` reverts
|
||||
the in-memory value and the facade shows the settings save-failure
|
||||
snackbar). A locked session cannot change it: the selector is disabled
|
||||
until the PIN is entered, and `setRelockMinutes` refuses while `active`,
|
||||
so a child cannot switch the idle relock off for the next unlock.
|
||||
- Header lock/unlock button and the `parental-lock-now` /
|
||||
`parental-unlock` palette commands.
|
||||
|
||||
## Startup footprint
|
||||
|
||||
The lock service, lock store and enforcement are on the renderer's initial
|
||||
path by necessity (the workspace resolver and the catalog data sources gate
|
||||
on them). Everything else stays lazy: the PIN dialog loads through
|
||||
`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load
|
||||
reads as a cancelled prompt), and the enforcement's Stalker step loads
|
||||
through `parental-lock-stalker-enforcement.ts` only while a Stalker route is
|
||||
open — a static import of the Stalker store would put the whole Stalker
|
||||
data layer back into `main.js`. The chunk is preloaded when a Stalker route
|
||||
opens, so a relock there runs the step synchronously; if it is not loaded
|
||||
yet (still fetching, or it cannot load — a stale PWA page after a
|
||||
deployment) the step fails closed at once by navigating to
|
||||
`/workspace/sources`: leaving the Stalker route clears its selection and
|
||||
stops its playback, and the Xtream step still runs. The feature costs about 35 KB of
|
||||
`renderer.initialBytes`; the baseline was not raised for it, the growth was
|
||||
offset by moving lazy-only barrel re-exports (backup/restore and the portal
|
||||
helpers) off the initial path.
|
||||
|
||||
## Lock store lifetime
|
||||
|
||||
On Electron the SQLite `categories.locked` index is derived from the lock
|
||||
store and must never lag behind it, because the worker filters every read
|
||||
by the index alone:
|
||||
|
||||
- The store commits BEFORE the re-stamp, so a failed re-stamp rolls the
|
||||
store back to the previous locks AND re-stamps every touched type from
|
||||
them (a backup restore stamps three types, and the ones before the
|
||||
failing type already carry the new locks). The store revision consumers
|
||||
reload on is published only once every touched type is stamped — also
|
||||
after a rollback — so a reload cannot read a later type through its old
|
||||
stamps. If the rollback write or its re-stamp fails too, the playlist is
|
||||
marked stale and re-stamped on the next store access. A failed rollback
|
||||
WRITE still puts memory back to the previous locks and leaves a pending
|
||||
rewrite: the next store access persists them before it re-stamps, so the
|
||||
failed edit cannot take effect through that re-stamp. The pending rewrite
|
||||
lives in memory: if the app exits before it lands, the next launch loads
|
||||
the persisted copy, i.e. the edit the parent submitted with the PIN. That
|
||||
is accepted — it is not a bypass, and a durable rollback journal would
|
||||
hit the same storage failure. For the whole
|
||||
re-stamp window — store changed, stamps not yet landed — the playlist
|
||||
counts as stale, so `readable` is false and a relock inside the window
|
||||
reloads fail-closed instead of through the old stamps. Such in-flight
|
||||
entries are not retried by a reconcile running beside the write (a PIN
|
||||
prompt or backup calling `ensureReadable()`): it would stamp from a store
|
||||
the write has not committed yet. Only failed stamps are retryable.
|
||||
- A write that removes a playlist's LAST lock clears the index first and
|
||||
drops the key afterwards: the launch-time reconcile finds playlists only
|
||||
through their key, so an interruption must leave store-with-lock and
|
||||
index-without, which the next reconcile repairs toward locked. If the
|
||||
clear or the store write fails, the index is re-stamped from the previous
|
||||
locks at once — title matching and multi-source discovery query the
|
||||
worker directly and trust the index, so a stale flag alone would not
|
||||
protect them. The playlist stays stale until the store write has landed
|
||||
too: while it is pending the index is already cleared but the store
|
||||
still holds the lock.
|
||||
- An edit that takes a lock away commits only while the session is
|
||||
unlocked, checked inside the write queue at commit time
|
||||
(`ParentalLockLockStore.setRemovalGate`, set by `ParentalLockService`).
|
||||
An editor opened while unlocked may still be saving, or queued behind
|
||||
another write, when the app relocks. The gate is asked right before the
|
||||
edit's first write is issued; a relock that lands after that is ordered
|
||||
after the write, which completes: the parent authorized that removal,
|
||||
and a post-write rollback could itself fail and leave the persisted
|
||||
store diverged from memory across a restart. (During the Xtream stamps
|
||||
the playlist is stale anyway, so the relock reloads fail-closed until
|
||||
they land.) Adding locks is always allowed.
|
||||
Playlist deletion and "Remove all playlists" are not edits and are not
|
||||
gated.
|
||||
- Every launch re-derives the index from the store for each playlist that
|
||||
has locks, and a store recovered by a later successful read marks its
|
||||
playlists stale the same way. The reconcile is awaited inside the store's
|
||||
`load()`, so `readable` (and with it every catalog read the renderer
|
||||
gates) stays false until the index agrees with the store; a re-stamp that
|
||||
keeps failing keeps the session fail-closed.
|
||||
|
||||
Every lock-store mutation runs through one write queue in
|
||||
`ParentalLockLockStore`: each rewrites the whole persisted store from the
|
||||
in-memory copy, so overlapping edits (two right-click toggles, a dialog
|
||||
save during a restore) would otherwise snapshot the same store and the
|
||||
later write would drop the earlier edit. Single-row changes (the
|
||||
right-click Lock/Unlock) are passed as an EDIT of the current list
|
||||
(`LockListEdit`), evaluated inside that queue, so two quick toggles each
|
||||
see the other's result instead of one shared snapshot; the dialogs pass a
|
||||
full list, which is the replacement they mean. A deleted playlist's locks leave
|
||||
the store through the `PLAYLIST_DELETE_CLEANUP` hook
|
||||
(`provideParentalLockPlaylistCleanup`, run by
|
||||
`PlaylistsService.deletePlaylist` for every single-playlist deletion; no
|
||||
re-stamp, the category rows go with the playlist), and "Remove all
|
||||
playlists" empties it through `ParentalLockService.clearAllLocks` once the
|
||||
deletion has succeeded — the in-memory store empties at once, and a failed
|
||||
persisted clear is retried on the next store access (the store is not
|
||||
`readable` until it lands). A backup restore that CREATES a playlist (not a merge) starts
|
||||
that playlist from empty locks, so a reused id can never inherit entries a
|
||||
failed cleanup left behind; the restore retries a failed lock-store read
|
||||
first (`ensureLocksReadable`) and aborts while it still fails, so that check
|
||||
never runs against the empty fail-closed snapshot.
|
||||
|
||||
## Backup
|
||||
|
||||
A backup carrying lock lists replaces the matching playlists' locks,
|
||||
possibly with an emptier set, so the import asks for the PIN first (after
|
||||
the file was chosen) and aborts when it is refused. That answer can go
|
||||
stale during a long import (idle relock, "Lock now"), so an entry whose
|
||||
restore would take a lock away asks again right before it replaces locks
|
||||
if the app has relocked; a refusal fails that entry and keeps its previous
|
||||
locks. A restore that only adds locks is not asked again. Locks are
|
||||
restored LAST
|
||||
for each entry, after the Xtream data restore, so a
|
||||
failed merge leaves the playlist's previous locks in place. M3U group titles
|
||||
travel verbatim (`normalizeParentalLockGroupTitles`, exact
|
||||
dedup): locks match `channel.group.title` exactly, so the trimming
|
||||
`uniqueStrings` used for favorites would weaken a lock on a title with
|
||||
surrounding whitespace. A backup's lock lists are validated entry by entry
|
||||
on import (`isWellFormedParentalLock*` in the shared contract): restore
|
||||
treats them as authoritative and the normalizer drops what it does not
|
||||
understand, so a damaged list is rejected rather than erasing the
|
||||
playlist's persisted protection.
|
||||
|
||||
`lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType,
|
||||
xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's
|
||||
`userState`, written only when the playlist has locks. Absent means "no
|
||||
opinion" and restore leaves the target's locks alone; present replaces them
|
||||
(`replacePlaylistLocks`, which also re-stamps the Xtream index). The PIN is
|
||||
never exported.
|
||||
|
||||
## Not yet covered (phase 2)
|
||||
|
||||
Favorites, recently viewed, playback positions / Continue Watching, the
|
||||
dashboard rails built from Stalker and M3U documents, global favorites /
|
||||
recent pages, the EPG guide, downloads and recordings lists, the remote
|
||||
control's channel-number resolvers, the command palette's channel search and
|
||||
the Electron-only global search page (`/workspace/search`, whose stored
|
||||
results are not re-run on a relock) still show rows from locked categories. The SQLite-side hooks exist
|
||||
(`unlockedCategoryCondition()` / `unlockedCategorySql()`), the renderer
|
||||
predicate is `ParentalLockService.is*Locked`.
|
||||
|
||||
## Files
|
||||
|
||||
- Contracts: `libs/shared/interfaces/src/lib/parental-lock.util.ts`,
|
||||
`parental-lock-pin.util.ts`, `Settings.parentalLock*`,
|
||||
`PARENTAL_LOCK_SET_STATE`.
|
||||
- Renderer: `libs/services/src/lib/parental-lock/` (`ParentalLockService`
|
||||
owns the session state and PIN flows; `ParentalLockLockStore` owns the
|
||||
persisted lock set, its unreadable state and the SQLite re-stamp),
|
||||
`apps/web/src/app/services/parental-lock-prompt.service.ts`,
|
||||
`parental-lock-enforcement.service.ts`,
|
||||
`apps/web/src/app/settings/settings-parental-section.component.*` and `settings-parental-lock.facade.ts` (rows anchored and indexed for settings search),
|
||||
`libs/ui/components/src/lib/parental-lock-pin-dialog/`,
|
||||
`libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/`.
|
||||
- Electron: `apps/electron-backend/src/app/database/parental-lock-state.ts`,
|
||||
`services/parental-lock-state.ts`, `events/parental-lock.events.ts`,
|
||||
`database/operations/category.operations.ts` (`setCategoryLocks`).
|
||||
@@ -169,8 +169,9 @@ harness, which is what the ratchet needs. The main process start
|
||||
|
||||
`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.
|
||||
`tools/performance/journey-baselines.json`. A J1 runtime baseline is added
|
||||
once its counter is deterministic on the CI runner; the launch counters are
|
||||
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
|
||||
|
||||
## `renderer.initialBytes`
|
||||
|
||||
@@ -273,6 +274,17 @@ the first measurements was otherwise `package.json` text embedded in
|
||||
`main.js`, which moved with every script edit; #1692 fixed that by importing
|
||||
only the version.)
|
||||
|
||||
Two effects make the exact counter move for reasons outside a PR's own diff.
|
||||
A baseline lowered on a branch that predates a concurrent `master` merge can
|
||||
sit below what the merged code measures: #1712 lowered it on a branch without
|
||||
#1714, so `master` measured 108 bytes over and every later PR failed the job
|
||||
until a follow-up moved lazy-only modules out of `main.js`. Re-run the job on
|
||||
an up-to-date branch before merging a baseline change. And the bundler's
|
||||
chunk-level identifier renaming shifts when a module enters or leaves
|
||||
`main.js`: moving one service out once renamed an imported identifier at 162
|
||||
call sites, eating about 320 of the bytes saved. Judge a small change by the
|
||||
`--stats-json` input sizes, not only by the counter.
|
||||
|
||||
The job also refuses a weakened baselines file:
|
||||
`tools/performance/check-baseline-direction.mjs` compares
|
||||
`journey-baselines.json` with the revision the change is measured against
|
||||
@@ -287,6 +299,36 @@ earned it, set `updatedAt` and `evidencePr`, and paste the measurement output
|
||||
into the PR. Never raise a value to make a PR pass: if growth is a deliberate
|
||||
trade-off, say so in the PR and let the maintainer decide.
|
||||
|
||||
The runtime counters come from the `Performance journeys` job of the same
|
||||
workflow, on `ubuntu-latest` only. It runs `pnpm run perf:journeys` under
|
||||
`xvfb-run` (the Nx target builds `electron-backend:build-performance`, the
|
||||
Playwright config starts the Xtream mock), writes the measurements to the job
|
||||
summary and uploads `dist/performance/journeys/` as the `performance-journeys`
|
||||
artifact. The `Performance journeys scope` job skips it only for pull
|
||||
requests that change nothing but Markdown, `docs/**`, `.plans/**`,
|
||||
`.codex/**`, `.claude/**`, `.changes/**` or `apps/website/**` (the E2E
|
||||
workflow's ignore list plus release notes); any other file, including root
|
||||
build inputs such as `.nvmrc`, `nx.json` or `tsconfig.base.json`, runs it.
|
||||
Pushes to `master` and manual dispatches always run it. The job is warn-only (`continue-on-error: true`) for its first two
|
||||
weeks (plan item B3): a regression marks the job failed without failing the
|
||||
workflow. Making it required is a maintainer decision.
|
||||
|
||||
No J1 runtime counter is enforced yet. Three dispatched runs on 2026-09-27
|
||||
(CI runs 36271875209, 36271879955 and 36271884616) reported the same summary
|
||||
values, `renderer.ipcCallsToFirstCard` 16 and
|
||||
`renderer.domMutationsToFirstCard` 939, but the third run marked both
|
||||
`stable: false`: its warm-up and one measured iteration reached the first
|
||||
card in about 750 ms with 13 bridge calls and 576 mutations, the others in
|
||||
about 1,400 ms with 16 and 939. The three extra calls
|
||||
(`downloadsGetDefaultFolder` and two `dbGetGlobalRecentlyAdded`) land before
|
||||
or after the first card depending on that race, so neither counter is
|
||||
promoted until the race is understood and the counters are deterministic.
|
||||
`renderer.layoutShiftScore` (0) and `renderer.longTasks` (2) were identical
|
||||
in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
|
||||
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
|
||||
differ from a Mac (12 and 571 there, the fast path without the Linux-only
|
||||
`getWindowState` call), so take J1 baseline values from the runner only.
|
||||
|
||||
## Adding a counter
|
||||
|
||||
1. Produce the value from the built output or from a deterministic probe, not
|
||||
|
||||
@@ -27,12 +27,36 @@ using its result as evidence. Docker validation can use
|
||||
| ---------------------------------- | ----------------------------------- |
|
||||
| Angular renderer entry points | `pnpm run typecheck:web` |
|
||||
| Electron main process entry points | `pnpm run typecheck:backend` |
|
||||
| Jest spec programs | `pnpm run typecheck:spec` |
|
||||
| Full unit suite (all projects) | `pnpm run test:unit:ci` |
|
||||
| EPG data access | `pnpm nx test epg-data-access` |
|
||||
| Workspace shell utilities | `pnpm nx test workspace-shell-util` |
|
||||
| Shared SQLite schema/connection | `pnpm nx test database` |
|
||||
| Packaging metadata | `pnpm nx test packaging` |
|
||||
|
||||
`typecheck:spec` (`tools/typecheck/spec-typecheck.mjs`) runs `tsc --noEmit`
|
||||
over every `tsconfig.spec.json` under `apps/`, `libs/` and `tools/`, a few
|
||||
programs at a time (`--concurrency=N` or `SPEC_TYPECHECK_CONCURRENCY`; a
|
||||
positional argument filters by path), and fails on any diagnostic. ts-jest
|
||||
transpiles with `isolatedModules`, so this is the only check that catches a
|
||||
spec whose types drifted from the code it exercises. CI runs it in the
|
||||
`unit-and-typecheck` job after `typecheck:ci`. Conventions the gate relies on:
|
||||
|
||||
- Spec tsconfigs use `module: preserve` with `moduleResolution: bundler`, the
|
||||
same as the library's own `tsconfig.json`; ts-jest forces CommonJS emit
|
||||
outside ESM mode, so the setting only affects type-checking, and `node10`
|
||||
resolution cannot see Angular's `exports`-only secondary entry points.
|
||||
- Each spec program lists `global.d.ts` in `files` so `window.electron` and the
|
||||
other ambient declarations resolve.
|
||||
- Libraries tested through `tools/testing/run-web-esm-lib-tests.mjs` are
|
||||
type-checked by `apps/web/tsconfig.spec.json`, the config
|
||||
`jest.web-esm.workspace.ts` hands to ts-jest; add a new ESM-tested library's
|
||||
spec globs there. `apps/web/src/jest-esm.d.ts` types `jest.unstable_mockModule`.
|
||||
- Type test doubles instead of casting to `any`: `jest.Mocked<T>`,
|
||||
`InstanceType<typeof SomeStore>` for signal stores, and
|
||||
`Object.defineProperty` or a writable mapped type for read-only capability
|
||||
flags.
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
@@ -71,6 +95,39 @@ behavior.
|
||||
Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits
|
||||
nonzero on a `Failed to collect coverage` marker, a missing or invalid project
|
||||
report, or a runtime-owning production TypeScript file absent from that report.
|
||||
It runs projects a few at a time, largest first, with a bounded Jest worker
|
||||
count per project (defaults: `min(3, cores - 1)` in flight and
|
||||
`ceil(cores / concurrency)` workers each; override with `--concurrency=N`,
|
||||
`--max-workers=N` or `TIER_A_CONCURRENCY` / `TIER_A_MAX_WORKERS`). Each
|
||||
project's output is printed as one block when it finishes, and the run ends
|
||||
with the wall-clock total and the longest projects. Spec `tsconfig`s set
|
||||
`isolatedModules: true`, so ts-jest transpiles files one at a time instead of
|
||||
type-checking each through a language service (the web configs already ran
|
||||
with `diagnostics: false`); `isolatedModules`-incompatible syntax such as a
|
||||
type re-export without `export type` still fails at load time, and spec type
|
||||
errors are caught by `typecheck:spec` (see Unit And Type Checks).
|
||||
|
||||
In CI, a pull request skips the Tier A suite (and the merged-coverage upload)
|
||||
when every changed file is outside Tier A test inputs:
|
||||
`tools/coverage/unit-coverage-scope.mjs` holds the allowlist (Markdown, `docs/`,
|
||||
notes and plans, agent guidance, workflows other than `ci.yml` and
|
||||
`build-and-make.yaml`, the website, E2E and mock-server apps,
|
||||
release/packaging/skills/performance tooling, and a `package.json` edit
|
||||
confined to non-`coverage:*` scripts). Anything else, including files no Nx
|
||||
project owns such as `jest.preset.js` or `tsconfig.base.json`, and every
|
||||
`{workspaceRoot}` input a Tier A test target declares, runs the full suite;
|
||||
master pushes always run it. `nx affected` is deliberately not used for this
|
||||
decision because a change to an unowned file affects no project. A node test
|
||||
parses every Tier A source for string literals that point at repository files
|
||||
outside the owning project (`tier-a-external-references.mjs`) and fails if the
|
||||
allowlist would skip any of them, so a new cross-project read cannot be
|
||||
silently exempted.
|
||||
Jest's transform cache is kept in `JEST_CACHE_DIRECTORY` and persisted with
|
||||
`actions/cache`: pull requests restore it, while only master pushes (starting
|
||||
from an empty cache, so it holds exactly the current tree) and maintainer
|
||||
dispatches save it. A shared Nx task cache is not used: Nx indexes its local
|
||||
cache in a machine-specific database, so a restored cache folder is never hit,
|
||||
and sharing it safely needs Nx Cloud or another supported remote cache.
|
||||
`coverage:merge` requires every configured Tier A report before replacing the
|
||||
merged output. Strict health validation also requires the merged Istanbul map
|
||||
itself to contain usable instrumentation for every runtime-owning Tier A file,
|
||||
@@ -135,6 +192,17 @@ After an E2E run, generate the semantic summary with:
|
||||
pnpm run coverage:e2e:summary
|
||||
```
|
||||
|
||||
CI runs the Electron suite as Playwright shards (`--shard=<n>/<total>`, split
|
||||
by spec file because the suite is sequential): three shards on Ubuntu and
|
||||
Windows, two on macOS, whose runners are the scarcest and queued longest. 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
|
||||
|
||||
@@ -52,7 +52,10 @@ Current workspace routes:
|
||||
8. `/workspace/search`
|
||||
9. `/workspace/downloads`
|
||||
10. `/workspace/settings/:section` (`/workspace/settings` redirects to
|
||||
`general`; the settings context panel links each section page)
|
||||
`general`; the settings context panel links each section page; sections:
|
||||
`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`,
|
||||
`parental` — see [parental lock](parental-lock.md) — `backup`, `reset`,
|
||||
`about`)
|
||||
11. `/workspace/xtreams/:id/...`
|
||||
12. `/workspace/stalker/:id/...`
|
||||
|
||||
@@ -169,7 +172,8 @@ restarting playback; remote commands retain captured playback order. See the
|
||||
|
||||
Search is shell-owned and route-aware:
|
||||
|
||||
1. Disabled on settings routes.
|
||||
1. On settings routes, searches the settings themselves (see
|
||||
[Settings search](#settings-search)).
|
||||
2. Enabled on sources routes.
|
||||
3. Enabled for `/workspace/search`, which is the Electron-only routed
|
||||
global-search view. `Ctrl/Cmd+F` in Electron opens this route and
|
||||
@@ -214,8 +218,10 @@ Rail navigation is also shell-owned:
|
||||
|
||||
Command palette behavior is shell-owned but view-extensible:
|
||||
|
||||
1. The shell resolves commands into three groups in fixed order: current view,
|
||||
this playlist, then global.
|
||||
1. The shell resolves commands into groups in fixed order: current view,
|
||||
this playlist, global, then settings. The settings group appears only for a
|
||||
non-empty query and holds at most six settings matches (see
|
||||
[Settings search](#settings-search)).
|
||||
2. Shell-owned commands are derived from route context and current playlist
|
||||
state; empty groups are omitted instead of rendering disabled placeholders.
|
||||
3. Workspace features contribute current-view commands through
|
||||
@@ -245,6 +251,51 @@ Command palette behavior is shell-owned but view-extensible:
|
||||
is disabled. The new player setting applies to the next playback session; an
|
||||
existing stream is not re-mounted.
|
||||
|
||||
### Settings search
|
||||
|
||||
Settings rows are searchable from the header search on `/workspace/settings`
|
||||
and from the command palette. Both use the same index and ranking.
|
||||
|
||||
1. The index is `SETTINGS_SEARCH_ENTRIES` in
|
||||
`libs/workspace/shell/util/src/lib/settings-search/`, published through the
|
||||
`@iptvnator/workspace/shell/util/settings-search` sub-entrypoint. Eager
|
||||
code imports the main shell util barrel, so the index stays out of it and
|
||||
ships only in lazy chunks (the initial-bytes ratchet enforces this).
|
||||
2. Each entry names its section, title and description translation keys,
|
||||
untranslated synonyms (`keywords`), runtime `requires`, and an optional
|
||||
`fallbackId`. Section definitions (`SETTINGS_SECTION_DEFINITIONS`) are the
|
||||
single source for the settings navigation too.
|
||||
3. Every titled `.setting-item` in the section templates carries
|
||||
`data-setting-id`. `settings-search-registry.spec.ts` fails when a row, id,
|
||||
title key or description key drifts from the index, so a new settings row
|
||||
must be added to the index in the same change.
|
||||
4. `SettingsSearchService.search()` matches the translated title and
|
||||
description of the current language plus the keywords; every query token
|
||||
must match (AND), and a label prefix outranks a word start, which outranks
|
||||
an inner match. Rows whose `requires` the runtime lacks are never returned.
|
||||
Embedded MPV rows depend on a lazy support probe
|
||||
(`ensureEmbeddedMpvSupportLoaded()`), run when the settings page or the
|
||||
command palette opens, never from shell bootstrap; frame copy also needs
|
||||
`frameCopyAvailable`, matching the settings page gate.
|
||||
5. Settings routes use `local-filter` search mode, so the term lives in `q`.
|
||||
While `q` is set, the settings page shows ranked results in place of the
|
||||
section page and the settings context panel shows per-section match
|
||||
counts, muting sections without matches. The search box is shown on
|
||||
settings even when no playlist exists.
|
||||
6. Choosing a result, pressing `Enter` in the header search (best match), or
|
||||
picking a settings command in the palette calls `reveal()`: it navigates
|
||||
to the section page without `q` (which clears the box) and the page
|
||||
scrolls to, focuses, and briefly highlights the row once the form is
|
||||
hydrated. A row hidden by the current form state falls back to its
|
||||
`fallbackId`, the control that makes it appear. A reveal must win over the
|
||||
typed term: `WorkspaceShellSearchSyncService` drops a keystroke still
|
||||
waiting for its debounce through `onReveal()`, and Enter does not apply
|
||||
the term first, because either `q` sync navigation would supersede the
|
||||
reveal navigation. Keyboard users keep a `:focus-visible` ring on the row
|
||||
after the highlight fades.
|
||||
7. `Ctrl/Cmd+F` on settings focuses the header search instead of opening
|
||||
global search.
|
||||
|
||||
Keyboard shortcut help is shell-owned:
|
||||
|
||||
1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`.
|
||||
|
||||
@@ -91,20 +91,37 @@ exit. The retained handle still provides the actual exit code and signal.
|
||||
The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces
|
||||
even when the smoke fails. Check the paused-frame screenshot and trace before
|
||||
classifying a zero rendered-frame signal as an infrastructure flake.
|
||||
The Snap and Flatpak `--embedded-mpv-runtime-probe` launches in
|
||||
`build-and-make.yaml` run under GNU `timeout --verbose -k 10 300` with
|
||||
`ELECTRON_ENABLE_LOGGING=1`, so a main process that throws before app ready
|
||||
and blocks on Electron's uncaught-exception dialog under xvfb fails within
|
||||
five minutes with a distinct `::error::` (exit 124, or 137 after timeout's
|
||||
announced KILL escalation) and its stderr in the step log, instead of holding
|
||||
the job until the 120-minute limit.
|
||||
|
||||
## Main-process ownership
|
||||
|
||||
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`, which bootstraps
|
||||
the database, registers events and creates the main window. The cache is
|
||||
`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` under the
|
||||
`files` option of the `package` and `make` targets, and `verify:package-layout`
|
||||
fails when either entry file is missing from `app.asar`. The preload is
|
||||
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
|
||||
|
||||
@@ -13,7 +13,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 |
|
||||
| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
|
||||
| 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) |
|
||||
@@ -39,6 +39,7 @@ are not prerequisites for reading repository contracts.
|
||||
| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
|
||||
| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
|
||||
| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
|
||||
| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill |
|
||||
| Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill |
|
||||
| VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
|
||||
| TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes |
|
||||
|
||||
Reference in new issue
Block a user