Files
iptvnator/docs/architecture/workspace-shell.md
T
e45cd85a78 feat(settings): PIN-protected parental lock for categories (#285) (#1601)
* feat(settings): add PIN-protected parental lock for categories (#285)

Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): guard locked categories on routes, PWA search and paging

- Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a
  locked category reached by URL prompts for the PIN and redirects to the
  section root on refusal (Electron row ids are mapped to provider ids).
- PWA search filters withheld categories like the catalog reads.
- Electron warm-cache detection confirms an empty, lock-filtered read with
  the unfiltered existence check instead of refetching from the provider.
- A Stalker lock flip past page 1 drops withheld rows at once and restarts
  the list from page 1 instead of appending onto stale pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): compile the PIN hashing helper in the Node backend build

The web backend compiles the shared interfaces library without DOM typings,
so the DOM-only `SubtleCrypto` / `BufferSource` names broke its Docker
build. The helper now describes the WebCrypto surface it needs structurally
and reaches it through `globalThis`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close the remaining parental-lock gaps from review

- Detail routes check the item's own category: a locked movie or series
  paired with an unlocked category id in the URL is still refused.
- `requestUnlock()` awaits the settings load before it can answer "not
  active", so a slow startup cannot open a management dialog unguarded.
- `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and
  releases the worker on switch-off; it no longer re-locks the worker on
  every ordinary settings save under a renderer that shows "unlocked".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor

- The Xtream detail guard hydrates the PWA session cache before judging an
  item on a cold navigation and fails closed when the catalog cannot place
  the item.
- The dedicated Stalker search route filters withheld genres, re-fires on
  lock changes, judges paging on the raw page and restarts from page 1 on a
  lock flip.
- The Xtream category dialog loads its lock candidates through the
  capability-selected data source; the PWA source now lists its raw
  categories with lock flags, so locks can be configured there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): arm the relock timer on enable and harden Stalker search relock

- The idle timer follows the unlocked transition instead of `active`, so the
  session that just enabled the lock still locks itself later.
- Stalker search closes an open detail whose genre became withheld on
  relock and advances by itself past pages made only of locked rows (only
  while they add ids the list has not seen).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): fail closed on unreadable settings and finish relock clean-up

- Unreadable settings (IndexedDB load failure) left the feature switch at
  its default and announced "unlocked" to the main process. A stored PIN
  now stands in for the switch, and without one nothing is announced, so
  the worker keeps its mirrored locked default.
- Lock applies run one at a time and abandon superseded results; the
  Electron data source keys its in-flight share by lock version so a
  relock can never reuse an unlock refresh's unfiltered rows.
- The stored in-portal Xtream search is re-run on a lock change.
- Stalker live/radio selections are judged by tv_genre_id, and both live
  layouts drop the playback of a channel whose category became withheld.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): fail closed on an unreadable lock store and make lock writes reliable

- A lock store that cannot be read is no longer treated as empty: while
  the lock is active every category is withheld (renderer predicates and
  set-based filters alike) until the PIN is entered or the store reads
  again, and writes are refused meanwhile so an empty in-memory store can
  never wipe the persisted locks. The lock set now lives in its own
  ParentalLockLockStore service.
- The M3U group dialog's lock write is awaited and a failed save is
  reported in a snackbar instead of being silently dropped.
- The Electron categories.locked re-stamp clears and re-locks inside one
  transaction, so a failed restamp keeps the previous index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store

- A Stalker search page issued before a relock was filtered with the
  pre-relock withheld set and could still be applied after it; the
  staleness check now includes the parental lock version.
- A lock store payload that does not parse or is not an object is a
  failed read (everything withheld until it reads again), no longer an
  empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps

- The window before the initial lock store read settles now withholds
  everything, like an unreadable store: settings can report the feature as
  on before the locks are known.
- The store commits before the SQLite index re-stamp; a failed re-stamp
  now rolls the store back, a failed rollback re-stamps on the next
  access, and every launch re-derives the index from the store.
- Xtream category/content reloads fail closed: a rejected reload empties
  the affected lists (content types drop back to idle) instead of keeping
  rows read under the previous lock state.
- Enabling/disabling the feature persists through one guarded path that
  undoes the in-memory switch and skips the Electron mirror on a failed
  settings write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(xtream): move the parental-lock reload specs beside the content spec

The content feature spec sits at the 1200-line spec cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed

- The lock store is readable only once the SQLite index has been re-derived
  from it, and a re-stamp that keeps failing keeps the session fail-closed,
  so catalog reads can never serve rows stamped unlocked by a stale index.
- While everything is withheld, Stalker rows without a genre are withheld
  as well (the store filter and the renderer predicate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries

- The Electron mirror of the feature switch is awaited; a mirror that
  cannot be written undoes the settings write, so a reload never starts
  from a mirror that disagrees with the persisted switch.
- A failed multi-type re-stamp rolls the store back AND re-stamps every
  touched type from it, since earlier types may already carry the new
  locks; a failed rollback keeps the playlist stale (fail-closed).
- A persisted lock store whose nested entries are not what writeLocks
  produces is a failed read, not an empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save

- A relock now fails closed immediately: the selected detail is stepped
  off against the lock store, the catalog lists and stored search results
  are emptied, and the filtered reloads publish only while the captured
  lock version is still current.
- Backups carry M3U lock titles verbatim (exact dedup), since the locks
  match group titles exactly.
- A relock-timeout write that fails reverts the in-memory value and shows
  the settings save-failure snackbar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store

- A write that removes a playlist's last lock clears the SQLite index
  first and drops the store key afterwards, so an interruption between the
  two can only leave a state the startup reconcile repairs toward locked.
- A PIN hash read failure is distinct from an absent PIN: the session stays
  locked and every PIN-protected step re-reads it first.
- Backup export awaits parental lock initialization and refuses to run
  while the lock store is not readable, since an absent lock field means
  "no opinion" on restore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration

- The Xtream category guard no longer runs the item check on category-only
  routes (Number(null) is 0), which prompted for the PIN on every unlocked
  VOD and series category while the lock was active.
- A lock change during the initial Xtream hydration withholds the rows the
  hydration publishes and runs the filtered reload once it has settled,
  on every path that marks the content initialized.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path

- The Xtream live layout resolves a playing channel's category through
  the unfiltered rows when the visible list lacks it (search can play a
  hidden category's channel); until that lookup lands the category is
  unknown and a relock stops the channel.
- A relock that overtakes the initial hydration now withholds the
  category publications too and reloads categories with the content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload

The Xtream store stays populated after leaving that portal, so its reload
runs on every apply; a locked M3U channel no longer keeps playing behind a
slow database or provider read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries

- The workspace route resolver awaits ParentalLockService.initialize()
  next to the settings load, so no route or catalog activates before the
  PIN and lock store are known.
- Every lock write re-reads a failed store before building its edit, so a
  recovered store is edited rather than overwritten.
- The deferred hydration reload runs under the publish guard of the
  request that deferred it.
- Backup import validates every parental lock entry and rejects a damaged
  list instead of erasing the persisted locks on restore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity

- A hidden-category lookup that lands after a later playback (same
  provider id, another playlist) no longer overwrites the newer channel's
  category; resolutions are generation- and playlist-checked.
- Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id
  or the row's cmd/name, so id-less rows no longer collapse onto one key
  and stall paging past locked pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): gate the Electron cached category/content reads while locks are unknown

The warm-route hydration reads the cache directly; it now returns nothing
while the lock store withholds everything, like the live reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps

- clearSearchResults() advances the search request version, so a search
  issued under the previous lock state cannot republish what a relock
  just cleared.
- A lock write publishes its store revision only once every touched type
  is stamped, so a reload triggered by it cannot read a later type
  through its old stamps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): retire a resolving Stalker live playback when the session relocks

The embedded player defers selecting the channel until its stream
resolves, so the enforcement service's cleared selection could not retire
the request; it now carries the lock version it was issued under and is
dropped when a relock happened meanwhile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(settings): one lock entry point per rail plus a right-click Lock/Unlock

- Stalker's dedicated lock button becomes the same "Manage categories"
  (tune) button the Xtream rail has; it opens the lock-only dialog, so
  every portal type shares one entry point and the rail header keeps
  three actions.
- Right-clicking a category (Xtream, Stalker) or an M3U group offers a
  single-row Lock / Unlock through the shared CategoryLockMenuComponent,
  behind the same PIN gate and lock store as the dialog.
- The settings hint explains where locks are set; group lock strings added
  to all locales (ru/de translated).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): serialize lock-store writes and drop a deleted playlist's locks

- Lock-store mutations run through one write queue: each rewrites the
  whole persisted store, so overlapping edits could otherwise snapshot
  the same store and the later write would drop the earlier edit.
- Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP
  hook; "Remove all playlists" clears the lock store once the deletion
  has succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): apply single-row lock toggles inside the lock store's write queue

The right-click Lock/Unlock (portal categories and M3U groups) built the
new list before entering the queue, so two quick toggles shared one
snapshot and the second dropped the first. Lock writes now accept an edit
of the current list, evaluated inside the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed settings read before any parental-lock settings write

updateSettings writes the whole settings object, which after a failed
startup read is the defaults; enabling the lock or changing the relock
timeout then replaced the user's persisted preferences. The read is
retried first and the write refused while settings stay unreadable. The
settings writes move to parental-lock-settings-writer.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): show the locked-groups row on the M3U rail and roll the relock timeout back to the recovered value

- The M3U groups rail now renders the same "N locked · Enter PIN to show"
  row as the portal category rail, so locked groups no longer vanish
  without an in-context unlock.
- A failed relock-timeout write rolls back to the value read after the
  settings retry, not to the pre-retry default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks

A lock-store clear that failed after "Remove all playlists" only logged,
so a later restore reusing a playlist id could inherit the deleted
playlist's locks. The in-memory store now empties at once and the
persisted clear is retried on the next access; a restore that creates a
playlist starts it from empty locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check

- The idle relock no longer interrupts a playing radio station: playing
  <audio> counts as activity, like video.
- A restore retries a failed lock-store read before checking a reused id
  for stale locks, and aborts while the store stays unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked

- A relock during a page-1 Stalker search now filters the rows already on
  screen at once, so old unlocked results are not clickable while the
  replacement page is pending.
- When every M3U group is locked the groups rail still renders, with its
  "N locked · Enter PIN to show" row, instead of the plain empty state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path

Master (#1712) moved the UI component barrel and the Stalker data layer out
of main.js and tightened the initial budget to 2 MB. The parental-lock
prompt imported the PIN dialog through the ui/components barrel and the
enforcement service injected the Stalker store at startup, which pulled
both back in (2.55 MB, over budget). The PIN dialog now loads through a
local lazy file on the first prompt, and the Stalker step loads only while
a Stalker route is open: initial total 1.65 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping

- Removing a playlist's last lock clears the SQLite index first; if the
  clear or the store write then 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 the stale flag alone did not
  protect them.
- A rollback publishes its store revision only after every type is
  re-stamped, so a reload cannot read a later type through the attempted
  stamps.
- docs: restore the index rules the earlier surfaces rewrite dropped from
  the contract, now in the Lock store lifetime section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the M3U groups view when every group is locked

With every group locked the filtered channel list is empty, so the
container showed its generic empty state and the groups rail's
"N locked · Enter PIN to show" row never appeared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed when the lazy Stalker enforcement step cannot load

A rejected chunk (e.g. a stale PWA page after a deployment) escaped
applyStalker(), so a locked Stalker selection kept playing after a relock
and the Xtream step was skipped. The step now leaves the Stalker route on
a load failure, which clears the selection and stops playback, and the
Xtream step still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): defer a stale Xtream hydration as soon as the catalog is withheld

On Electron a relock that overtook the initial content hydration waited
for the category reload before the content reload set the deferral flag;
the older unlocked hydration could publish its streams in that window.
withholdCatalog() now sets the flag itself, before anything is awaited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories

- A backup restore now writes the parental locks last, so a failed Xtream
  merge leaves the playlist's previous locks in place instead of the
  backup's possibly smaller set.
- The Stalker lock dialog snapshots the category list before its lazy
  import and opens only if the route is unchanged, so another portal's
  categories can never be saved under this playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store

- On relock the synchronous fail-closed steps (M3U channel, Stalker
  selection, the locked Xtream detail, catalog lists, stored search) run
  immediately instead of queueing behind an earlier apply that may still
  wait on a slow or hung read.
- The post-reload Xtream checks decide by the lock store through the
  unfiltered category rows rather than by absence from the reloaded list,
  which also omits merely hidden categories; unreadable rows fail closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps

- A backup carrying lock lists replaces the matching playlists' locks,
  possibly with an emptier set; the import now asks for the PIN (after the
  file was chosen) and aborts when it is refused.
- While a write re-stamps the SQLite index the playlist counts as stale,
  so a relock inside that window reloads fail-closed instead of through
  the old stamps. The internal store write now needs only a readable
  store, so a rollback can still land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): drop parental-lock Xtream reloads once the playlist is switched

A reload issued for playlist A no longer publishes into the shared Xtream
store after the user opened playlist B: the store's reloads guard on the
playlist they read for, and the enforcement apply retires its search
refresh and selection checks on a playlist switch as on a newer lock
version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts

A backup merge now asks for the PIN again right before it replaces a
playlist's locks when the app relocked during the import, instead of
relying on the answer given at the start. The wrong-PIN count and the
30-second pause move from the dialog into the lock service, so
dismissing and reopening the prompt no longer resets them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): refuse lock removals that commit after a relock and keep the index stale until the store write lands

Lock edits that take a lock away now commit only while the session is
unlocked, checked inside the write queue at commit time, so an editor
save still in flight (or queued) when the app relocks cannot remove
locks. Adding locks stays allowed. The Xtream category dialog drops its
lock draft after a relock, and a backup restore re-asks the PIN only
when it would remove a lock. Clearing a playlist's last lock keeps its
index stale until the store write has landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): clear an Xtream detail from a hidden category synchronously on relock

The synchronous relock step now clears a selected Xtream item whose
category the visible category list cannot place (a manually hidden
category opened through search), instead of leaving it usable until the
awaited reloads and lookup finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter

A new runtime capability requires the lock-state and index-stamping IPC.
When Electron reads Xtream through the SQLite worker without it (a
partial or older preload), the locked session withholds every category
instead of trusting a worker that never learned the lock state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-check lock removals at their durable commit points

A lock removal that passed the unlocked check before its write is asked
again right after the store write and, for Xtream, after the index
stamps. A relock in between writes the previous store back or rolls the
stamps back before anything is published. The stale-index bookkeeping,
index stamping and store merge move into helpers to keep the lock store
within the file size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retry a failed revert of a refused lock removal and fail closed meanwhile

When writing the previous store back after a relock-refused removal
fails, the lock store now keeps a pending rewrite, is not readable (the
locked session withholds everything) and rewrites the persisted store
from memory on the next access, so a restart cannot load the removal.
A failed "Remove all playlists" clear shares the same retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): authorize lock removals when issued and close genre-less Stalker details in fail-closed mode

A lock removal is now authorized right before its first write is issued;
a relock that lands after that is ordered after the write, which
completes. This drops the post-write rollback, whose own failure could
leave the persisted store diverged from memory across a restart. The
Stalker search closes a detail without a genre on relock while every
category is withheld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): restore the previous locks when an Xtream rollback write fails

When an Xtream lock edit's re-stamp fails and the rollback store write
fails too, memory now goes back to the previous locks and a pending
rewrite persists them on the next store access before the index is
re-stamped, so the failed edit cannot take effect through that re-stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stalker): cover page-one rows leaving the screen on relock while the reload hangs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): offer the portal unlock row in fail-closed mode

When the lock store cannot be read every portal category is withheld
but no locked ids are known, so the rail showed no "Enter PIN to show"
row. It now shows the row without a count in that state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN

Catalog title matching and multi-source discovery query the SQLite
worker directly; they now return nothing while the parental lock
withholds everything (unreadable store or a bridge without the worker
filter). The Stalker lock dialog captures its playlist, provider and
section before the PIN prompt and re-checks them after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps

The VOD multi-source host keys its discovery session to the parental
lock version: a lock change drops the discovered sources, retires
discoveries and switches in flight, and rediscovers through the
worker's new lock state. Stale-index entries of a write still stamping
are no longer retried by a concurrent reconcile, which could re-stamp
from a store the write had not committed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist

A locked session can no longer change the relock timeout: the Settings
selector is disabled until the PIN is entered and the service refuses
the change while locked. An M3U right-click lock toggle now captures its
playlist before the PIN prompt and is saved only if that playlist is
still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): reset a locked M3U channel however it becomes active

The enforcement service now checks the active M3U channel whenever it
changes while locked, so numeric zapping, next/previous and remote
commands, which select from the full channel list, cannot start a
channel of a locked group. Numeric zapping also skips such a channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): bind the M3U group management result to its playlist

The groups view captures the playlist before the PIN prompt and drops
the management dialog's hidden and locked group lists once another
playlist is open, so they cannot be saved under that playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(parental-lock): record the accepted restart case of a failed rollback write

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): build bulk lock drafts only from a readable lock store

The Xtream and M3U management dialogs offer lock toggles, and the
Stalker lock dialog opens, only once the lock store has been read. A
draft built from the empty fail-closed snapshot would otherwise replace
the real locks with nothing on Save if storage recovered in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): keep the parental lock switch on the saved state and roll back to the recovered value

The Settings switch snaps back to the saved state when clicked and
follows it once the PIN action succeeds, so a cancelled or refused PIN
no longer leaves it showing the opposite state. A failed switch write is
undone to the value read after the settings retry instead of the
hard-coded inverse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): match noncanonical PWA Xtream category ids against their locks

The PWA data source compared raw provider category ids such as "009"
with locks stored as numbers, so such a category stayed visible while
locked. Both sides are now compared in canonical numeric form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): close the M3U group editor on any relock

The group management dialog lists every group name, locked ones
included, even when it opened without lock toggles (unreadable lock
store). It now closes on any relock, and the groups view re-checks the
lock state before opening it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 16:38:34 +02:00

698 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Workspace Shell
This document records the current workspace-first shell contract. It is the
stable replacement for the older UI refactor summary.
Related:
- [Workspace Dashboard](./workspace-dashboard.md)
## Summary
- `/workspace` is the primary app surface.
- `WorkspaceShellComponent` owns the persistent frame: rail, header, optional
context panel, content outlet, and external playback footer.
- Descendant workspace pages inherit `layout = 'workspace'` from the
`/workspace` root route.
- Provider route trees now bootstrap through route-scoped session providers
instead of nested provider shell components.
Core implementation:
1. `apps/web/src/app/app.routes.ts`
2. `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts`
3. `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html`
4. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts`
5. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts`
6. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts`
7. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts`
8. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts`
9. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts`
10. `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts`
11. `libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts`
12. `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts`
13. `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
## Route Contract
Current workspace routes:
1. `/` -> `/workspace`
2. `/workspace` -> functional redirect `workspaceEntryRedirect`
(`WorkspaceStartupPreferencesService.resolveInitialWorkspacePath()`;
`/workspace/dashboard` by default, `/workspace/sources` when the dashboard
is disabled, or the last restorable route under
`StartupBehavior.RestoreLastView` — `dashboard` itself is guarded by
`dashboardAccessGuard`)
3. `/workspace/dashboard`
4. `/workspace/sources`
5. `/workspace/playlists/:id/:view` (plus `favorites` and `recent` siblings)
6. `/workspace/global-favorites`
7. `/workspace/global-recent`
8. `/workspace/search`
9. `/workspace/downloads`
10. `/workspace/settings/:section` (`/workspace/settings` redirects to
`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/...`
Compatibility redirect:
1. `/settings` -> `/workspace/settings`
Provider route integration:
1. `apps/web/src/app/app.routes.ts` marks the `/workspace` root route with
`data.layout = 'workspace'`.
2. `isWorkspaceLayoutRoute(...)` treats that layout marker as inherited route
state for all descendants.
3. Xtream and Stalker parent routes attach route-scoped session providers that
bootstrap the active playlist, sync provider section state, and clean up
provider-local state when the route is destroyed.
4. Xtream route bootstrap is DB-first for already imported Electron playlists:
if the requested section has persisted categories and content, the route
hydrates from SQLite even when the portal status probe reports unavailable,
expired, or inactive. Fresh/no-cache Xtream routes still use the status probe
to block remote imports before the loading overlay starts.
5. Workspace routes no longer rely on nested provider shell components for
hidden local chrome.
## Shell Structure
The shell is intentionally split into four persistent regions:
1. Left rail:
1. Static workspace links for dashboard, sources, global favorites, and
recently viewed. The routed global-search rail link is Electron-only
because its data source is the SQLite worker bridge.
2. Provider-aware context links derived from the active or current playlist.
3. Settings remains a persistent footer shortcut in the rail.
2. Top header:
1. Playlist switcher.
2. Route-aware search input and command palette trigger.
3. Add source action.
4. Optional playlist refresh and route-specific shortcut actions.
5. Downloads shortcut in Electron.
3. Main body:
1. Optional left context panel.
2. Main router outlet content.
4. Optional footer:
1. External playback session bar when a docked session is visible.
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
services:
1. `WorkspaceShellRouteStateService` owns current route parsing, rail links,
context-panel state, dashboard startup preference, and playlist source
signals.
2. `WorkspaceShellSearchService` owns the route-aware header search capability
and public search actions.
3. `WorkspaceShellSearchSyncService` owns the search query signals, debounced
application, provider-store synchronization, and query-param sync.
4. `WorkspaceShellHeaderService` owns playlist title/subtitle, account/info
actions, refresh action state, and recent-items bulk cleanup.
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
and recent-command recording.
6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay
state and labels.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
## Context Panel Rules
The shell decides which secondary panel to show from the current route:
1. `/workspace/sources`
1. `WorkspaceSourcesFiltersPanelComponent`
2. Xtream category sections (`live`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
3. Stalker category sections (`itv`, `radio`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
4. `/workspace/settings/:section`
1. `WorkspaceSettingsContextPanelComponent`
5. Downloads sections
1. `WorkspaceCollectionContextPanelComponent`
The context panel is part of the shell contract. New workspace-level routes
should explicitly decide whether they need one rather than adding local
sidebars inside feature pages.
Xtream and Stalker category panels preserve provider/server category order by
default. The panel header exposes a sort menu next to category search with
`Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active,
synthetic "all categories" entries stay pinned before sorted provider
categories.
On live sections the category panel can be folded away independently of the
channels list; the folded panel is reachable as a popover from the channels
header through the `LIVE_CATEGORIES_POPOVER` token the shell provides. The
three nested levels, their affordances and persistence are specified in
`iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar").
A category click in a LIVE section (Xtream `live`, Stalker `itv` and `radio`)
changes only the selected category: the live layouts gate their player on the
store's selected item, so the handler must not clear it — the channel the user
is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker:
`onStalkerCategoryClicked` returns before `clearSelectedItem()`). VOD and
series category clicks do drop the open detail (`setSelectedItem(null)` /
`clearSelectedItem()`) because they navigate to a list route.
The channel header offers **Show playing channel** when browsing excludes the
active channel. It returns to that category and focuses the row without
restarting playback; remote commands retain captured playback order. See the
[queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order).
## Search And Navigation Rules
Search is shell-owned and route-aware:
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
focuses/selects the header search input instead of opening a fullscreen
dialog.
4. Enabled for supported Xtream and Stalker content/search views.
5. Placeholder text and search handling vary by provider and section.
6. Input changes are debounced before route/store updates are applied.
7. Global search uses the header input as its primary input and writes the
search phrase to the `q` query parameter, so history/back-forward behavior
matches the rest of the workspace.
8. The URL is authoritative for the search box only when it carries search
intent. `WorkspaceShellSearchSyncService` re-reads `q` on every
`NavigationEnd`, but an **app-initiated** navigation that stays on the same
page and carries the term already applied is always ignored — whether or
not a debounce is still pending. Otherwise a page writing an unrelated
query param (a downloads filter chip, a refresh bump) or the router echoing
back our own trimmed `q` would reset the box to the applied term, eating
everything typed since: the whole word while the first keystroke is still
debouncing, or a just-typed trailing space once the debounce has fired
("Bein " would snap to "Bein" and typing on would yield "BeinSports").
Applied terms are always stored trimmed (`applySearchQuery` and
`setSearchState` both trim), so the echoed `q` compares directly; the box
keeps exactly what the user typed. Pages are free to write their own query
params while the user types; they must not assume the shell will re-apply
the search afterwards.
9. Browser history overrides that guard. The exemption is keyed on
`Navigation.trigger === 'imperative'`, so back/forward always re-applies
what the history entry carries, even mid-typing.
10. Applying a term explicitly supersedes a queued one. `applySearchQuery()`
cancels any pending debounce, so the Enter key committing a trimmed term
cannot be overwritten a moment later by the untrimmed keystroke still
waiting behind it.
Rail navigation is also shell-owned:
1. Workspace-global entries are static.
2. Provider entries come from `buildPortalRailLinks(...)`.
3. On dashboard, sources, settings, global search, global favorites, and global
recent, the shell falls back to the currently selected playlist so provider
navigation remains available even outside a provider route.
Command palette behavior is shell-owned but view-extensible:
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
`WorkspaceViewCommandService`.
4. Header shortcut actions can opt into palette exposure by attaching palette
metadata through `WorkspaceHeaderContextService`.
5. Filtering matches command labels, descriptions, and keywords, and keyboard
selection always lands on the first enabled command.
6. A "Recently used" section is rendered above the standard groups when the
query is empty and at least one stored id resolves to a visible+enabled
command; ids are persisted via `RecentCommandsService` (capped at 5,
stored at `STORE_KEY.RecentCommands`). Storage is **not** pruned by route
visibility — a navigation command like `Open sources` is invisible while
the user is on `/workspace/sources` but the id stays in storage so it
reappears in the recent section after navigating away.
7. Six "Switch player to X" commands are registered globally by
`WorkspacePlayerCommandsContributor` (VideoJS, HTML5, ArtPlayer, Embedded
MPV, MPV, VLC). Each command carries a `requires` flag gating its
visibility: the MPV/VLC ("managed-external") entries are visible only when
`RuntimeCapabilitiesService.supportsManagedExternalPlayers` is true, and the
Embedded MPV ("embedded-mpv") entry is visible only after the command
palette lazily preloads an async `window.electron.getEmbeddedMpvSupport()`
check and it resolves to `supported` (mirroring the Settings dropdown gate).
Do not run this Embedded MPV support check from workspace shell bootstrap:
supported desktop builds may load the native addon while resolving
capabilities. The entry matching the current `SettingsStore.player()` value
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`.
It owns the workspace-scoped `document:keydown` listener for `?` /
`Shift+/`.
2. The listener ignores events from inputs, textareas, selects, and
content-editable elements via `isTypingInInput(...)`.
3. `libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts` is the
metadata registry for shortcuts shown in the help dialog and documented in
README. `keyboard-shortcuts.ts` owns the display transformation and help
trigger detection.
Shortcuts that only work through the Electron bridge, such as embedded MPV
controls, must set `electronOnly: true` so the PWA dialog does not advertise
unavailable commands.
4. New custom shortcuts should be added to that registry when the handler is
added. Do not include native browser/editor behavior such as `Tab` or
platform text editing shortcuts.
## Window Chrome And Custom Title Bar
The Electron window hides the native title bar on all desktop platforms
(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`):
1. macOS keeps the native traffic lights (`titleBarOverlay: true`,
`trafficLightPosition`); the renderer draws no window buttons.
2. Windows and Linux use renderer-drawn window controls
(`app-window-controls`, `libs/ui/components/src/lib/window-controls/`).
`frame` is intentionally left untouched so native resize borders and
window snapping keep working.
The controls are mounted once in `app-root` (not inside the workspace
header) as a `position: fixed` top-right overlay so they stay clickable
above full-window content such as Material dialog backdrops — the same behavior as the macOS traffic lights. Because
CDK overlays render as popovers in the browser top layer (above any
z-index), the component host is itself a `popover="manual"` element: it
enters the top layer on init and re-enters it (hide + show) whenever
another popover opens, so the controls always paint last. The
`z-index: 10000` remains only as a fallback when the popover API is
unavailable. They render only when
`RuntimeCapabilitiesService.usesCustomWindowControls` is true (Windows/Linux
Electron with the window-control bridge methods available); the PWA and
macOS never mount them.
IPC contract (constants in `libs/shared/interfaces/src/lib/ipc-commands.ts`,
handlers in `apps/electron-backend/src/app/events/window.events.ts`):
1. `WINDOW:MINIMIZE`, `WINDOW:TOGGLE_MAXIMIZE`, `WINDOW:CLOSE`,
`WINDOW:GET_STATE` are `ipcMain.handle` channels resolved from the sender
WebContents. Close goes through `win.close()` so the existing
window-bounds persistence in `app.ts` still runs.
2. `WINDOW:STATE_CHANGED` is pushed main → renderer on
maximize/unmaximize and on the fullscreen events —
`enter/leave-full-screen` plus the `enter/leave-html-full-screen`
variants emitted for HTML-element fullscreen (video player
fullscreen) — so the maximize/restore glyph stays correct for
externally triggered changes (double-click on a drag region, OS
snap, F11). The controls hide themselves while the window is
fullscreen.
Window state is **never re-read at event time**. `attachWindowStateEvents`
seeds `{ isMaximized, isFullScreen }` once at window creation and each
event patches only the flag it names; every push carries a copy of that
tracked state. On Windows both getters can still report the
pre-transition value while the matching event fires — `isFullScreen()`
stays `true` during an HTML fullscreen exit, and `isMaximized()` reads
`false` while the window is fullscreen. Because the renderer replaces
both flags on every push and no later event corrects a stale one,
polling left the controls hidden forever after leaving fullscreen and
stuck the maximize/restore glyph on the wrong icon. Regression coverage:
`app-window-state.spec.ts` and `window-controls.e2e.ts`.
The pushed `isFullScreen` is the OR of two flags tracked apart: native
(OS-level, fed by `enter/leave-full-screen`) and HTML-element (fed by
the `*-html-*` pair). Electron remembers when the window was already
natively fullscreen before the player entered HTML fullscreen and then
leaves ONLY the HTML state on exit — no `leave-full-screen` fires and the
window stays fullscreen — so a single flag cleared by
`leave-html-full-screen` would un-hide the controls over a window that
is still fullscreen. A fullscreen launch (below) or F11 followed by the
player's `F` → `Esc` makes that path routine on Windows/Linux.
3. `WINDOW:TOGGLE_FULLSCREEN` toggles OS-level fullscreen (`setFullScreen`)
and, like the maximize toggle, reports the requested state and leaves
the `WINDOW:STATE_CHANGED` push authoritative. Because the transition is
asynchronous — `isFullScreen()` reports the old value until it lands,
and on Windows even while the matching event fires — a toggle must
never be decided against the getter. Every native fullscreen request
goes through the tracker in
`apps/electron-backend/src/app/services/native-fullscreen-transitions.ts`
(the F11 toggle AND the startup fallback below). Like the state pushes
above it keeps its own per-window fullscreen state, seeded from the
getter once at window creation (`trackNativeFullScreen` in
`initMainWindow`, while no transition can be in flight) and fed
afterwards only by the `enter`/`leave-full-screen` events, which also
cover transitions the app did not request. A toggle is decided against
the pending target while a transition is in flight, else against that
tracked state: two quick presses are an enter-then-exit, not two enters,
and F11 during the startup animation leaves fullscreen instead of asking
for it again. The tracker observes and never issues a request on its
own. The pending record is the LATEST requested target: an event landing
on it clears it, an event landing on the other state (an earlier request
of a burst landed first; ours is still queued) leaves it in place so the
next press still follows the user's latest intent, and a record older
than `FULLSCREEN_TRANSITION_TIMEOUT_MS` (2 s) is ignored. Events cannot
say which request they belong to, so any automatic "repeat the target"
on a mismatch is indistinguishable from reversing the user's own
green-button/Ctrl+Cmd+F action and is deliberately not done: should a
platform ever drop a queued request (Electron queues them on macOS and
applies them synchronously elsewhere), the record expires, the event-fed
state takes over and the next press corrects the window. The renderer
binds it to
**F11** in `WorkspaceKeyboardShortcutsService` (deliberately not gated
by `isTypingInInput` — it must work from any focus, because it is the
only exit from a fullscreen launch on Windows/Linux, where the title bar
is hidden and the controls hide themselves) and skips the key while
`document.fullscreenElement` is set, since the player's own `F` / `Esc`
own HTML fullscreen and F11 must not yank OS fullscreen out from under
it. Without a bridge (PWA) F11 is left to the browser.
Startup window mode (`Settings.startupWindowMode`, issue #1455):
1. `normal` (default) / `maximized` / `fullscreen`, chosen in Settings →
General ("Window on startup"). Electron only — the select renders only
when `RuntimeCapabilitiesService.supportsStartupWindowMode` sees both
`updateSettings` and `toggleFullScreenWindow` on the bridge, so the mode
is never offered without its F11 exit.
2. Settings live in the renderer's IndexedDB, which the main process cannot
read at window creation, so the `SETTINGS_UPDATE` handler mirrors the
value into electron-conf (`STARTUP_WINDOW_MODE`, the same pattern as the
frame-copy flag) and `initMainWindow` reads it synchronously. A change
therefore applies on the next launch. Both sides normalize through
`normalizeStartupWindowMode`, so junk never reaches the config file or
the window options.
3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux
the window is created hidden and enters fullscreen before its first
paint. macOS ignores the option while the window is hidden (an NSWindow
only toggles fullscreen once it is on screen), so `ready-to-show` repeats
the request with `setFullScreen(true)` right after `show()` wherever
`isFullScreen()` is still false — never unconditionally, or the
platforms that honoured the option would animate a second toggle. The
saved bounds stay spread into the options — they are the normal bounds
the window returns to, and the close handler keeps persisting
`getNormalBounds()`. `maximized` calls `maximize()` inside
`ready-to-show` right before `show()`, never earlier: `maximize()` on a
hidden window shows it, and a blank window would flash.
4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can
sit anywhere in argv; the playlist-path extractor already skips every
`-`-prefixed argument) forces `fullscreen` for that launch only and is
never persisted. Resolution lives in
`apps/electron-backend/src/app/services/startup-window-mode.ts`. The
switch is consumed by the first window (`launchFullscreenSwitchConsumed`
in `App`): on macOS the process outlives its last window and the Dock
re-creates it through the same `initMainWindow`, which must then follow
the stored setting only. A second-instance launch carrying the switch is
ignored — the window already exists.
5. Deliberately not offered: kiosk mode (removes the exit path) and
"remember last state" (bounds persistence stores normal bounds only; an
explicit choice is clearer). Regression coverage: `app.spec.ts`
("startup window mode"), `settings.events.spec.ts`,
`window.events.spec.ts`, and the startup-window-mode cases in
`settings.e2e.ts`.
Zoom level (Cmd/Ctrl and +/−/0, issue #1109):
1. The packaged renderer runs under `file://` with path routing. Chromium
keys per-host zoom by the FULL URL when a URL has no host, so every
`pushState` to another section owns a separate zoom entry: after a route
change `webContents.getZoomLevel()` already reports that entry (usually
0) and the next visual-properties sync — a window resize, a display
change — snaps the renderer back to it. Chromium persists those per-URL
entries in `Preferences` on its own, which is why the level used to
"appear briefly" on `index.html` at startup and then reset. Dev mode
(`http://localhost:4200`) is per-host and never shows this, so only a
packaged or `ELECTRON_IS_DEV=0` run can verify zoom behaviour.
2. Restore therefore happens in the preload, not the main process:
`applyPersistedZoomLevel` (`api/preload-zoom-level.ts`) asks for the
stored level over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC at preload
start and applies it with `webFrame.setZoomLevel`, which installs a
TEMPORARY, frame-bound zoom level. It survives in-page navigation and
resizes, the zoom shortcuts (point 4) step it through the same call, and
`getZoomLevel()` reports it regardless of the route.
`webContents.setZoomLevel` from the main process would write the per-URL
entry and re-create the bug. When nothing is stored the preload re-applies
the current level for the same reason: entering temporary mode makes the
first zoom shortcut URL-independent too. A failed request is swallowed
and only costs this load its restore.
The apply is deferred to `DOMContentLoaded` — never at preload start and
never from a `setTimeout`: on Linux and Windows a `webFrame.setZoomLevel`
that early leaves the hidden window without a first frame, `ready-to-show`
never fires, `show()` never runs, and the renderer gets no animation
frames (the splash `main.ts` removes in a `requestAnimationFrame` stays).
macOS is unaffected and CDP-driven tests force frames, so only the
packaged Linux/Windows E2E asserting the splash is gone caught it
(`legacy-playlist-migration.e2e.ts`, defer-epg). After the parser
finishes the call is harmless and still lands before the first Angular
paint. The preload then sends `WINDOW:ZOOM_LEVEL_APPLIED`.
3. Chromium never persists temporary zoom, so `services/window-zoom-level.ts`
owns the electron-conf key `ZOOM_LEVEL`: the applied acknowledgement (not
the request — between the two the sender's `getZoomLevel()` is still the
per-URL default) marks the sender
as owning the level, `persistZoomLevel` writes it back from the window
`close` and app `before-quit` handlers (the bounds-only saves of before,
folded into `persistWindowState`), and `attachZoomLevelPersistence` also
writes it on every main-frame cross-document `did-start-navigation` —
a reload drops the temporary level, and by `did-finish-load` the new
document's preload has already read whatever was stored. That
navigation also releases ownership until the next preload answers, so a
close mid-reload cannot save the per-URL default over the user's level.
4. The shortcuts are a renderer key binding, not a native menu: the
Windows/Linux window calls `setMenu(null)`, so no accelerator could reach
it there. `WorkspaceKeyboardShortcutsService` (`libs/workspace/shell`)
listens on the document like it does for F11 and resolves the chord with
`resolveZoomShortcutAction` (`libs/portal/shared/util`): Cmd on macOS,
Ctrl elsewhere, never Alt; `+`/`=` (so `Ctrl+=` and `Ctrl+Shift+=` both
zoom in), `-`/`_`, `0`, and the numpad `+`/`-`/`0` (by `code`, since a
NumLock-off `0` reports `Insert`). Keys are matched by `event.key`, so
non-US layouts zoom with their own `+`/`-` keys. Like F11 it is not gated
by the typing-target check — browsers zoom from any focus — and a key
another handler already `preventDefault`ed is left alone. The binding
calls the synchronous, preload-local `window.electron.adjustZoomLevel`
(`adjustFrameZoomLevel` in `api/preload-zoom-level.ts`), which steps the
frame's temporary level through the same `webFrame.setZoomLevel` as the
restore and returns the level applied — never a main-process
`webContents.setZoomLevel`, which would re-create the per-URL bug. The
step and limits live in `libs/shared/interfaces/src/lib/zoom-level.util.ts`
(`stepZoomLevel`): 0.5 per press, Electron's own `zoomIn`/`zoomOut` role
step (≈10 %), clamped to levels −4…6 (≈48 %…299 %, inside Chromium's
25–500 %), off-grid levels snapping to the next grid point in the pressed
direction; `Ctrl/Cmd+0` returns to level 0. A stored level already
outside the limits (the macOS menu roles never clamped) is never moved
against the request: a press further out leaves it, a press back in
lands on the limit. Persistence needs nothing
extra: the main process reads the live level back (point 3). On macOS the
default application menu still carries the `zoomIn`/`zoomOut`/`resetZoom`
roles, but Chromium hands a key equivalent to the web contents first and
Electron performs the menu equivalent only in
`WebContents::PlatformHandleKeyboardEvent`
(`shell/browser/api/electron_api_web_contents_mac.mm`, Electron 43.3.0),
the unhandled-keyboard-event hook — a `preventDefault`ed keydown never
gets there, so the binding keeps one press at one step. CDP-dispatched
keys (the E2E) never reach the menu at all.
Without a bridge (PWA) the browser keeps its own zoom, and the help
dialog lists the chords as Electron-only. Regression coverage:
`window-zoom-level.e2e.ts` presses the real shortcuts (in, out, numpad,
reset) and measures the rendered factor (content width ÷
`window.innerWidth`) across a section change, a resize, a reload and a
restart; key resolution and the bridge step are unit-tested in
`keyboard-shortcuts.spec.ts`, `workspace-keyboard-shortcuts.service.spec.ts`
and `preload-zoom-level.spec.ts`.
Reloading the renderer on an in-app route:
1. The packaged renderer is `dist/apps/web/index.html` over `file://` and
Angular routes by path (no hash strategy), so once the user is on a
section the document URL is `file:///…/web/workspace/sources` — a path
with no file behind it. A reload of that URL fails with
`ERR_FILE_NOT_FOUND` (-6) or is cancelled outright, depending on who
starts it. Two user-reachable triggers: the macOS default application
menu (nothing calls `Menu.setApplicationMenu`, so View › Reload / Force
Reload are live; Windows/Linux drop the menu bar via `setMenu(null)`),
and the settings unsaved-changes guard, which calls
`window.location.reload()` after the user confirms a reload intent on
`/workspace/settings/<section>`. Dev mode (`http://localhost:4200`) never
shows either — the dev server serves the index for every path.
2. Both legs live in `services/renderer-reload-fallback.ts` and end in the
same `restoreRendererRoute`: load the packaged index with the routed
URL's route — its path relative to the renderer root plus query and
fragment (`resolveRoutedRendererUrl`) — in the `restoreRoute` query
parameter.
- A main-process reload (`webContents.reload()`, the menu role,
DevTools) fires no `will-navigate`, so it cannot be redirected up
front: it fails, Chromium commits `chrome-error://chromewebdata/`
and `app-root` stays empty until the app restarts.
`attachRendererReloadFallback` recovers it after the fact from the
main-frame `did-fail-load` with `ERR_FILE_NOT_FOUND`
(`resolveReloadedRendererRoute`); other error codes, subframes and
non-`file:` URLs are left alone. The recovery load is deferred to
the error page's `dom-ready` and never issued from inside
`did-fail-load`: a `loadFile` started while Chromium is still
committing the error page yields a document that never receives
animation frames — the splash stays, nothing paints, while
`document.visibilityState` still says `visible` — and the same load
after `dom-ready` paints normally (Electron emits `did-fail-load`
before that `dom-ready`). A cross-document navigation starting in
between withdraws the pending recovery, so a stale `dom-ready` can
never re-load the index over a newer navigation.
- A renderer-initiated reload (`location.reload()`, the settings
guard) does fire `will-navigate`, where the routed URL is not the
trusted index and `handleRendererNavigation` would cancel it —
silently, so the confirmed reload simply never happened. The handler
now recognizes a routed renderer URL and sends it straight to the
index with its route, with no failed load in between; every other
untrusted navigation is still blocked (external URLs still open in
the browser).
A failed `index.html` itself is never re-requested (it would loop):
`resolveRoutedRendererUrl` rejects the index, and the recovery load
carries `index.html` as its path, so a second failure cannot recurse.
3. The renderer consumes the parameter before Angular bootstraps:
`apps/web/src/main.ts` calls `resolveRestoredRendererRoute`
(`libs/shared/interfaces/src/lib/renderer-reload-route.util.ts`, which
also owns the parameter name) and installs the result with
`history.replaceState`, so the router's initial navigation lands on the
route the user was on. The route is resolved against `document.baseURI`
(the packaged `<base href="./">`, i.e. the renderer directory — the same
prefix Angular strips from `location.pathname`), and anything that would
leave that directory (an absolute URL, another scheme, a `..` escape)
is dropped with only the parameter removed, so the app boots at its
default route instead of following an arbitrary target.
4. Zoom persistence is unaffected: the failed reload's
`did-start-navigation` already saved the level and released ownership,
the recovery load's `did-start-navigation` is then a no-op, and the new
document's preload restores the level as after any other reload. The
main-process close guard also treats the recovery like any full
navigation (`did-navigate` disarms it).
5. Regression coverage: `renderer-reload.e2e.ts` reloads from the main
process (`webContents.reload()`, the menu role) on Sources and from the
renderer (`window.location.reload()`, the settings guard) on a settings
section and asserts a NEW document is rendered on the same route with
the parameter gone (`renderer-reload.support.ts` marks the old document,
since the URL alone is identical before and after);
`window-zoom-level.e2e.ts` reloads the same way. Unit coverage:
`renderer-reload-fallback.spec.ts`, `renderer-reload-route.util.spec.ts`,
`app.spec.ts` ("renderer reload recovery").
Layout integration:
1. `document.body` gets a `frameless-platform` class (set in
`AppComponent`, same mechanism as `dark-theme`) — body-level so rules
also reach cdk-overlay content rendered outside `app-root`.
2. `apps/web/src/styles.scss` reserves `padding-right: 150px` in
the top-aligned drag region (`.workspace-header`) for the 3 × 46px
button strip.
3. Button colors follow the theme via CSS variables (`--app-on-surface`,
`--app-hover-overlay`); the close button uses the Windows-style red
hover (`#e81123`). No theme IPC is involved.
Window decorations on Linux (shadows, corners):
1. Hiding the title bar removes the window manager's decorations, so the
shadow/rounded corners must come from client-side decorations (CSD).
Electron 43 enables rounded corners by default when the Linux desktop
environment supports CSD. GTK drop shadows and extended resize boundaries
remain environment-dependent; Electron selects native Wayland automatically
on Wayland sessions.
2. Linux environments without CSD support can still show a square,
undecorated window. Windows 11 keeps its DWM rounded corners and shadow
because the standard frame is retained.
Toolchain notes for the Electron 43 baseline:
1. `better-sqlite3` remains pinned exactly so native dependency updates happen
deliberately with database-worker, packaging, and Electron E2E validation.
Version 13 uses Node-API and ships its supported platform binaries in the
package. It must not be listed in pnpm's `onlyBuiltDependencies`: forcing an
implicit `node-gyp rebuild` bypasses those binaries and makes installation
depend on the host compiler toolchain. The old `node-abi` override belonged
to v12's removed `prebuild-install` path and is no longer required.
2. Local development supports **Node 22.22.3–22.x or 24.15.0–24.x**, declared in
`engines` as `^22.22.3 || ^24.15.0`. Use `.nvmrc` (currently 22.23.2) for
development and CI. Angular 22 sets this supported LTS range and requires
TypeScript `>=6.0 <6.1`. `electron-builder` 26.15.7 also pulls `@electron/rebuild` 4,
which requires Node 22.12 or newer, and the root `postinstall` runs
`install-app-deps` on every `pnpm install`. The 26.15.7 minimum also
carries the v26 backport that fully extracts the Snap template's `.tar.7z`
payload; 26.15.0–26.15.6 can
produce a Snap that is missing `desktop-init.sh`.
3. Dependabot keeps Electron, native database, packaging, EPG parser, and
version-locked Shaka/mpegts updates out of the shared npm minor/patch group.
Those dependencies require standalone PRs so their dedicated package,
worker, playback, and diagnostic-contract validation cannot be hidden by an
unrelated grouped update.
4. Electron 42 and newer download their development binary on the first
Electron command instead of during package `postinstall`, so `electron`
must not remain in pnpm's `onlyBuiltDependencies` allowlist. The first local
`pnpm run serve:backend` can include a one-time download; use `pnpm exec
electron --version` to prewarm it before an offline run.
Known caveats:
1. DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native
caption buttons or the Window Controls Overlay get that).
2. Double-click-to-maximize on drag regions is handled natively by
Electron/Chromium; on Linux the exact behavior depends on the window
manager.
## Maintenance Guidance
Use this document as the source of truth when changing workspace shell behavior.
1. New top-level user destinations should default to child routes under
`/workspace`.
2. Shared provider navigation logic belongs in portal-shared util/UI libraries,
not duplicated inside the shell.
3. If a provider route changes how playlist/session bootstrap works, update the
route-session provider and shell-facing route contract together.
4. When adding a non-native keyboard shortcut, update the shared shortcuts
registry, the help dialog tests, README, and the closest behavior test.
5. Historical migration notes, cleanup lists, and one-off refactor steps should
stay out of this file; track them in issues or PR notes instead.