forked from unom/punktfunk
23f1debe691fcd2ba7a17170b9d91919cadfbfa7
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f66de3eba4 |
fix(web): close the last two high-severity items from the closeout audit
**The streamed-screen pin could still be clobbered by three other write paths.** Deferring it to the server's value on Save fixed the reported sequence but not the general case: the draft is only re-seeded while it is CLEAN, so once there is an unsaved edit its `capture_monitor` is frozen at whatever it was before the operator used the picker — and `applyAxis` (which spreads the last saved policy), the built-in preset switch and the custom-preset apply all put that stale value back. Every write path reads `serverCaptureMonitor()` now; no path spreads the draft's copy. **The session⇄game grace input had no accessible name.** That card has its own `Field` and only DisplayCard's was fixed, so the number input was still announced as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control, `fieldset`/`legend` for the two button groups. Verified in a browser — zero inputs without an accessible name across the Displays page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f2e1b9872c |
fix(web): four "fixes" from this branch that did not actually fix anything
A verification pass re-read every finding from the original sweep against the code on this branch rather than against the commit messages. It found that four of them were still broken, two because the edit I made was inert. Commit messages claim; code decides. - **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook` as a bare directory name, and tsc silently skips dot-prefixed directories in that form — so the entry typechecked nothing at all. Proved it by planting `export const __probe: string = 1` in `.storybook/preview.tsx` and watching `bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same probe now fails as it should. - **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status to pending in the same update, so it cleared a flag that was already changing. The green "PIN sent" note therefore still greeted the next pairing attempt over an empty PIN box. It now resets on the transition that actually matters: `pin_pending` going false → true. - **The session⇄game controls had the enforcement flag inverted**, and I never touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build enforces everything", when the contract says the opposite in as many words: "Empty on a platform with no launch path (macOS), so the console can say so instead of offering a switch that does nothing". On exactly the platform the flag exists for, every control stayed live and reported success for an axis the host would never act on. Absent still means "assume it acts" — that is the compatible reading for an older host, and a different case from present-empty. - **Logout stopped revoking after a restart.** The epoch was a module-level counter starting at 1, so it revoked within one process run and then reset — and since the seal key derives from the stable mgmt token, a cookie captured before a restart unsealed fine and was accepted again for the rest of its 7-day TTL. One service restart undid the whole fix. It persists next to the host's config now. Verified: log out, restart the console, the captured cookie still 401s, a fresh login still works. Two more the pass rated as partial, both worth closing: - The plugin-UI response filter was a denylist of four header names, so `Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session` and sign the operator out of the console, on our own origin, because the iframe is same-origin by design. It is an allowlist now; a plugin-supplied CSP, `X-Frame-Options` or CORS header no longer speaks for us either. - A half-configured TLS setup now refuses to start instead of logging a warning and serving anyway. Neither shape can work — one path missing puts the login password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the cookie Secure so the browser drops it and login can never stick. Exiting with a reason beats a console that looks fine and is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b9b0df349d |
fix(web): the charts stop lying about time, and logging out logs you out
The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
de17ceb8f8 |
fix(web): a newly installed plugin shows up on its own, and the display form can be used without a mouse
Installing a plugin left the sidebar unchanged until a reload. The reason is timing, not caching: the host restarts the scripting runner AFTER the job reports done, and the plugin only registers its UI once that comes back — several seconds later, by which point the one-shot invalidation had already run and found the old list. The nav then waited out the 30 s idle poll, which in practice meant "until I reloaded". Anything that changes the installed set now switches the directory to a 2 s poll for a minute, so the entry lands about a second after the plugin actually comes up. Measured end to end in a browser: 29 s → 7 s, with the plugin registering at 6 s. The plugin entries also never animated. They are rendered outside the `motion.nav` that carries the variants and the stagger, so they inherited neither and simply appeared — most visibly in exactly the case above, where one shows up in a nav that is already on screen. They get their own animation container now, matching the main nav. (A motion-wrapped div around the link, not `motion(Link)`, which erases TanStack's typed `params`.) The accessibility pass on the display form, where the console's densest controls live: - The Custom block's numeric inputs had a `<label>` with no `htmlFor` next to an `<input>` with no `id`, which labels nothing at all — a screen reader announced them as unnamed spin buttons. Single controls are paired properly now; the button groups became real `<fieldset>`/`<legend>`, which is what they are. - Every option group signalled its active choice with fill colour alone. They carry `aria-pressed` now, so the state is available to assistive tech and not only to people who can compare two button variants. - `QueryState`'s error branch is a live region, so a query that fails announces the failure instead of silently swapping one region for another. - Motion honours `prefers-reduced-motion` instead of overriding it. - `<html lang>` follows the locale instead of claiming "en" while the app renders German. Verified: switching to de flips the attribute. - "Close menu", "Language" and "Loading" went through the message catalogue. Also: ten dead message keys removed (a whole removed Clients page and the old Settings token field), and the README no longer tells operators to set the management token under "Settings → API token" — that field is gone and the token has been server-side only for some time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e505aba41 |
fix(web): the console stops swallowing the host's answer when it says no
The host writes genuinely useful refusals — "entry is owned by provider `x`, update it through its reconcile" — and a dozen call sites threw them away. The pattern was always one of two: a mutation whose `error` nothing rendered, or an `await mutateAsync(...)` with no catch, which additionally produced an unhandled rejection. Either way the operator clicked, nothing visible happened, and the thing they asked for silently hadn't. Fixed at each site, with the host's own message shown where there is one: - Adding or editing a library entry kept the form open and said why, instead of closing it as if it had saved and taking the typing with it. Deleting one reports the refusal rather than leaving the card sitting there. - The GPU preference, capture start/stop, recording delete and download, and the dashboard's stop-session / request-keyframe / end-game all report failure. The failed capture STOP is the one that mattered most: it is "stop & save", so a swallowed error meant minutes of recording vanished with nothing on screen. - The recordings Download had a comment claiming the detail view surfaces its errors. It only does that for the selected row, and Download is on every row. Two related fixes in the same area: - `apiFetch` no longer navigates to /login synchronously from inside whichever call noticed a 401 — very often a background poll the user never started. Tearing the page down mid-render took unsaved editing state with it, which the Displays page explicitly models. It defers a beat and coalesces, so a burst of parallel 401s schedules one navigation. - The plugin liveness probe treated the auth gate's 302 → /login → 200 HTML as a healthy plugin, and rendered the console's own login page inside the plugin's iframe. It also gave up permanently on the first failed probe, so the runner restart at the end of every install threw away whatever was open in another plugin. It rejects the redirect and keeps probing on a slower beat while down. `apiErrorMessage` moves out of the display card into src/lib/errors.ts, since half the console needs it now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4a5d4b0a71 |
feat(web): the console follows the host's events instead of asking ten times a minute
The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e30d94573a |
fix(web): the logs come back after a host restart, and eight more that quietly lied
The Logs page died permanently every time the host restarted — which the console's own update flow does. The host's log ring restarts at seq 1 while the page's cursor stays where it got to, and `GET /logs?after=8000` against a fresh ring is not an error, it is an empty page forever: no error, no dropped badge, stale lines on screen, and nothing short of a full reload to get out. A restart always breaks the poll first, so a failed poll now triggers a re-read from the start of the ring, and a page whose newest entry is older than what we hold is recognised as the sequence having restarted. Follow mode also stopped following at exactly the wrong moment. The autoscroll effect was keyed on the rendered row count, which pins at the 1000-row DOM cap — so once the log got busy enough to matter, the effect never re-ran again. It is keyed on the newest rendered seq now. And pausing now actually pauses: stopping the interval left React Query's focus/reconnect refetches landing, which evicted the very lines the operator had paused on. The rest: - A plugin could white-screen the whole console by registering `icon: "constructor"`. The icon map is a plain object, so the inherited key resolved to `Object`, which is truthy — the fallback never fired and React was handed `Object` as a component, from inside the app shell. - Saving a display arrangement deleted the saved position of every device that was not connected at that moment: the host replaces the whole map, and we only ever sent the displays we could see. - Flipping DDC, PnP or dedicated-game-sessions committed whatever unsaved edits the Custom block was holding, then cleared the "unsaved" badge so there was no trace of it. Those three apply on top of the SAVED policy now. - Saving the Custom block put the streamed-screen pin back to whatever it was when the form was seeded, undoing a change made in the picker below it. - "End now" on a running game calls the host's only stop, which ends EVERY live session; on a grace row with no app id it ended every waiting game. Both say so first now, when there is more than one to lose. - Edit and Delete were offered on library entries owned by a provider plugin, which the host refuses with 409 — silently. They are attributed instead. - An install whose first poll failed never polled again, and one whose host restarted spun forever with no way to dismiss it. - Submitting a second pairing PIN showed the previous attempt's "PIN sent" before a digit was typed, and the paired list it points you at never refreshed. - The streamed-screen picker claimed an env pin during every slow load, and rows that cannot be picked now look that way instead of silently eating the click. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
50531c8e9e |
fix(host/display): a screen picker that cannot stream a screen now says so instead of saving
The Windows console's "Streamed screen" picker SAVED and then did nothing. `capture_monitor` is
platform-neutral, so the PUT persisted; every consumer of it is Linux-gated, so a virtual display
was still created on connect. The operator got a control that acknowledged the click and changed
nothing — the worst of the three possible behaviours.
The root cause is not a missing gate, it is a missing BACKEND. Per-monitor capture is Linux/portal
only: `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]` because `pf-capture` has no
Windows entry point that can capture an arbitrary head. Its sole Windows entry point is
`open_idd_push`, a frame channel pushed by our OWN IddCx virtual display; DXGI Desktop Duplication
was deliberately REMOVED (`windows/dxgi.rs` keeps the GPU-preference hook only to stop DXGI
reparenting the virtual display off the pinned adapter, and says so). So there is nothing for a pin
to aim at, and exposing the picker got ahead of that.
Decision of record (user, this session): mark it unsupported now rather than build the Windows
backend here. A Windows mirror backend is a real feature gap and a project of its own — it needs a
duplication capturer plus everything the IDD-push path carries today (cursor sidechannel, 444,
10-bit, PyroWave, HDR) re-plumbed through it — not a follow-on to an enumeration commit.
Three places, so the answer is consistent wherever it is asked:
* `MonitorsResponse.pin_supported` — a CAPABILITY, reported by the build that would have to honor
the pin rather than sniffed from the OS client-side. When a Windows mirror backend lands this
flips and the console needs no change. `pinned` stays `None` off-Linux deliberately, and now says
why: it is what the console highlights as "sessions stream this", and highlighting a head nothing
will capture is the same lie in a different place.
* `enforced` drops `capture_monitor` off Linux. That list is exactly the "which controls are live vs.
coming soon" contract, and claiming this one unconditionally is what let the picker ship enabled.
* The PUT drops a non-Linux `capture_monitor` instead of storing it, and logs that it did. COERCED,
not rejected with a 400: this PUT is WHOLE-OBJECT, so a host that already stored a pin would have
every later settings save rejected over a field the operator cannot see — taking the other axes
down with it. This way such a policy self-heals on the next write.
Console: the picker renders read-only with an explanation, reusing the `envLocked` shape rather than
inventing a second one (`locked = envLocked || !pinSupported`). Managed heads were ALREADY excluded
from selection here (`mon.enabled && !mon.managed`), which is the "grey out, never filter blindly"
rule working as intended — the heads stay listed and explicable. `pin_supported` defaults to TRUE
when absent so an older host, which only ever shipped this picker where it worked, is not
retroactively locked out.
⚠️ `api/openapi.json` is hand-patched — punktfunk-host does not build on macOS, so the spec could not
be regenerated from the binary. The shape was verified by running the real generator over it: orval
emits `pin_supported: boolean` (required, no `?`) on `MonitorsResponse`.
Verified: `bun run codegen` (orval + paraglide + i18n parity: 434 messages, en + de), `bun run build`,
`bun run lint` (tsc --noEmit) all clean. Rust verified separately on .173 — see the next commit's
note if that lands, since neither xcheck target covers punktfunk-host.
|
||
|
|
3b11288c97 |
fix(web): unsaved custom display settings are visible and recoverable
Everything else on the Displays page auto-applies — a preset click, the game-session choice, the experimental toggles — but the Custom block does not, and its Save button sat at the bottom of a block taller than most viewports. People edited, never scrolled far enough to find it, navigated away, and silently lost the lot. The draft is now compared against the last-seeded server value, and that one boolean drives the whole affordance: a badge in the card header (visible without scrolling), an amber ring plus a title on the block, and a sticky action bar pinned to the viewport for as long as any part of the block is on screen. The bar states which of the two states you are in rather than leaving it implied, Save disables when there is nothing to save, and Discard changes puts the stored policy back. Two ways edits could still vanish are closed: a preset click now confirms before overwriting pending edits, and a reload/close warns. Re-picking Custom while already on Custom is a no-op instead of re-seeding, which would otherwise fill in defaults for unset fields and report "unsaved changes" for a click that changed nothing. Adds a `warning` badge variant + --warning token for the pending state — attention, not failure, and distinct from the destructive red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit c4318609c0da5ae1313081c5e488f685504f70d1) |
||
|
|
8fe71be424 |
fix(web): applying a saved preset kept switching the streamed screen off
`applyCustomPreset` builds a FRESH policy object instead of spreading the draft, so every field it doesn't name is dropped. `capture_monitor` was not named — so picking one of your own saved presets silently took a host that was mirroring a real monitor and put it back on a virtual display. The same omission in the Custom switch would have done it there too. Found on-glass against a running serve on .136: the PUT is whole-object, so the console is the only thing keeping the orthogonal axes alive, and these two sites were the ones building a policy from scratch rather than from what was already in force. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d461d889c3 |
feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read once at startup, which a console picker can never write — so it becomes a field of the display policy (orthogonal to presets, like game_session), and `vdisplay::capture_monitor()` resolves env-over-policy: an appliance that pinned in its unit's host.env stays pinned there, and a console click cannot re-aim a machine whose operator already declared the answer. Read per `open` rather than cached, so a picker change takes effect on the next session instead of the next host restart. The host re-resolves the input anchor on every policy write — including clearing it when the pin is cleared, or a later virtual-display session inherits an anchor aimed at a monitor it is not showing. `with_manual_layout` carries the pin through like the other orthogonal axes: without that, saving a display ARRANGEMENT would silently swap the streamed screen back to virtual. Console: a "Streamed screen" card listing the host's real monitors beside "Virtual screen (default)", saving on selection. A disabled head is listed but not selectable (so "why isn't it here?" has an answer), and an env-pinned host renders read-only with the reason rather than offering controls that silently lose to the environment. Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes sessions to the mirror and anchors input; setting the env to a different connector overrides it, exactly as documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
35b6c835fd |
docs: on a gamescope session the display outranks the end-game setting
On-glass on .41: with `game_on_session_end: keep` — "leave it running, nothing
is ever closed" — a deliberate stop ended the game, and a drop ended it ~14 s
later. Both times the LEASE did the right thing (no `ending the launched game`
line); the game died because the virtual display was torn down, and a nested
launch runs inside that gamescope.
So on a dedicated gamescope session the game's lifetime IS the display's, and
keep-alive decides it — not this setting:
* a deliberate stop skips the linger by design, so the game goes at once,
* a drop lingers the keep-alive window, then takes the game with it,
* only keep-alive Forever actually keeps it — and `always` still ends it at
the reconnect window, which is the documented precedence and was verified
(a `forever`-pinned display released at grace expiry, 60.006 s).
That makes the console's promise untrue on the most common Linux setup, so the
card now says so for every option rather than only warning about `always`, and
the docs get the three cases as a table. Worded so a non-gamescope host reads it
and moves on — a desktop-session launch is an ordinary process and none of it
applies.
Not changing behavior here: making `keep` pin the display would pit it against
"a stop must not leave a ghost display", and which wins is a product call rather
than something to decide silently mid-test.
web: codegen + tsc + biome clean, en/de complete. docs-site: build + tsc clean.
|
||
|
|
6a2c127ce9 |
feat(mgmt/web): show the running game, and who decides when it ends
The host knows what it launched and, after a disconnect, that it is counting down to closing it. None of that was visible: the console showed a stream with no game, and a game on its way to being closed was something you found out about afterwards. The Dashboard gains a running-game card above the session card — box art matched against the catalog it already fetches, so no new endpoint and no request on the 2 s status poll. "End now" means the two different things the row's state implies: a live game ends by stopping its session (what then happens to the game follows the policy — stopping a session is not licence to close a game), while one already waiting out its reconnect window has no session left to stop and is ended directly. The settings card sits next to the display keep-alive policy because that is the same question one step out: keep-alive decides how long a *display* outlives a disconnect, this decides whether the *game* does. The copy says plainly what `always` costs, that a drop is not someone pressing Stop, and that a display kept forever is unaffected either way — the precedence rule that would otherwise surprise someone. Where a build enforces nothing (macOS, no launch path) the controls are shown disabled rather than hidden: "does nothing here" is information. Also fixes the story fixtures, which had gone stale against the `games[]` field Phase 1 added, and adds a story for the state the card exists for — a game whose client walked away. web: build + tsc clean, biome-formatted; en/de messages complete. |
||
|
|
75b3c94f60 |
fix(web): console sweep — pairing, displays, stats, logs, auth, i18n
Pairing: - Refresh the paired-devices list after a native PIN pairing (the happy path never invalidated it, so a newly paired device stayed hidden until remount). - Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so it now reads "PIN sent" instead of a false "Paired successfully". - Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could never receive a PIN there. - Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8). Displays / Library: - "Arrange displays" save refreshes the settings card (it rewrites the policy), without clobbering unsaved Custom edits (re-seed only when the draft still matches the server). - Live-display list wrapped in QueryState so errors don't read as "no displays". - "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo artwork (was dropped on save); per-card delete pending. Stats: - Distinct colour for the native "queue" latency stage (it collided with "capture"). - "Not measured on this path" note on the GameStream health chart; configured-bitrate target line on throughput; host-authoritative elapsed timer; LiveCard surfaces non-404 errors. Shell / auth / i18n: - SSR-stable locale: first client render matches the base-locale SSR (no hydration mismatch), then adopts the persisted/browser locale post-hydration. - BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced into a post-login redirect loop. - Logout checks the POST result before navigating; logs dedup by seq (StrictMode); login "next" keeps query/hash; Dashboard shows the active-session count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a011aebef5 |
feat(host,web): experimental PnP monitor-devnode disable for Exclusive sessions
Second experiment against the connected-but-dark-head stutter (field-proven on the reporter's box: unplugging his standby HDMI TV removes a metronomic ~4 s double-jolt; DDC/CI is a dead end for TVs — measured on the lab LG, VCP 0xD6 gets no I2C ACK). An Exclusive isolate only removes physical monitors from the CCD topology; their PnP devnodes stay live, so every standby wake (auto input scan, Instant-On HPD cycling) still triggers the full Windows reaction: PnP arrival/removal, CCD re-evaluation, DWM invalidation — the suspected hiccup mechanism (Apollo #368's Device-Manager-refresh signature). - New `pnp_disable_monitors` display-policy axis (default off): orthogonal to presets like game_session/ddc_power_off, surfaced in GET/PUT /display/settings + the enforced list, carried through the layout transform. - windows/monitor_devnode.rs: after the isolate takes, disable exactly the deactivated monitors' devnodes — CCD target → monitor device path (DISPLAYCONFIG_TARGET_DEVICE_NAME) → PnP instance id → CM_Disable_DevNode with CM_DISABLE_PERSIST, so a hot-plug RE-ARRIVAL stays disabled (that persistence is the whole point). Teardown re-enables BEFORE the CCD restore (+300 ms re-arrival settle) so restored paths have their monitors back. Precise selection — co-installed third-party virtual displays are never touched. - Crash safety: instance ids journal to <config>/pnp-disabled-monitors.json before disabling; serve startup re-enables leftovers from a crashed host. Worst case is documented in the console help (Device Manager re-enable). - Web console: second Experimental-badged toggle (the DDC block refactored into a shared ExperimentalToggle), EN/DE strings, preset-switch carry. Verified: Linux 263 tests + clippy + fmt clean; Windows (RTX box) 220/220 + clippy clean; web tsc + production build clean; openapi.json regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cca5008805 |
feat(host,web): experimental DDC/CI monitor power-off for Exclusive sessions
The sole-virtual-display stutter investigation's active experiment: when the Exclusive isolate deactivates a physical monitor, the dark-but-connected head keeps getting serviced (monitor standby auto-input-scan / DP link churn) at a seconds-scale cadence — the leading suspect for the periodic double-jolt. A panel commanded off over DDC/CI (the VESA monitor-control channel in the video cable) believes it has an owner and, on cooperating firmware, stops probing. - New `ddc_power_off` display-policy axis (default off): orthogonal to presets like game_session, stored in display-settings.json, surfaced in GET/PUT /display/settings + the enforced list, carried through the layout transform. - windows/ddc.rs: VCP 0xD6 power-mode control via the dxva2 Physical Monitor API. Deliberately DPMS-off (0x04, DDC stays responsive, signal return wakes) and never power-button-off (0x05, bricks-until-button on many monitors). Probe-before-write; every failure is skip-and-log — monitors without DDC/CI, OSD-disabled, or behind docks/KVMs degrade to a logged no-op. - Manager wiring: panels commanded off immediately BEFORE the Exclusive CCD isolate (an HMONITOR — and with it the DDC channel — only exists while the display is active); teardown wakes them right after the CCD restore, where returning signal alone already wakes most firmware. - Web console: an Experimental-badged on/off control on the display card, applied immediately like the game-session axis and preserved across preset switches; EN/DE strings incl. the wake-failure escape hatch (press the monitor's power button once, turn the option off). Diagnostic value on top of the fix: if this kills a reporter's stutter, the churn is monitor-firmware-initiated; if only topology=primary/extend does, the driver services dark heads regardless — the two remaining root-cause classes. Verified: Linux 258 tests + clippy + fmt clean; Windows (RTX box) 220/220 + clippy clean; web tsc + production build clean; openapi.json regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8743f63406 |
polish(web): favicon, Punktfunk casing, display-save toast, idle stop button
- Favicon: the brand-mark lens SVG at /favicon.svg, linked from the root head. - Branding: user-facing "punktfunk" → "Punktfunk" (tab title, app_name, pairing prose, the logo aria-labels/SVG titles) — protocol identifiers (punktfunk/1, _punktfunk._udp, binary/module names) stay lowercase. - The display policy auto-saves on every preset pick / field edit with no signal; mount @unom/ui's sonner Toaster in the root and fire a success toast from the save funnel's onSuccess (new i18n key, en + de). First toast usage in the console — errors stay inline as before. - The stop-session button rendered destructive-red even with nothing to stop: variant switches to the muted `secondary` while no session is active. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
79c4f4cbc1 |
feat(host): user-defined custom display presets
Save named bundles of the display-management policy (the six behavior axes
plus the game-session axis) as custom presets, alongside the built-ins. A
custom preset is data — stored in <config>/display-presets.json — not a Preset
enum variant, so DisplayPolicy::effective() stays pure and the built-in set is
untouched; applying one writes a Custom policy via the existing PUT
/display/settings.
- policy.rs: CustomPreset/CustomPresetInput + load/add/update/delete store
- mgmt.rs: GET/POST /display/presets + PUT/DELETE /display/presets/{id},
surfaced on GET /display/settings
- web console: custom-preset cards with save-as / edit / delete + i18n
- regenerated api/openapi.json; docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
1992eb1c52 |
feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.
- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
registry pools only what it owns, so gamescope managed/attach outputs are no
longer double-owned by the registry AND the gamescope restore worker (fixes
the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
(kill_unit, applied to our transient unit AND the autologin stop -- validated
live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.
Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b28ddfc2d4 |
fix(web): clearer topology/identity copy, capped description width, mobile More-nav, preset spacing
Console polish on the Virtual displays card + shell: - Topology help now leads with the streamed display's role (Extend/Primary/Exclusive) instead of the confusing physical-monitor-only framing; notes the headless case. Identity help spells out the actual behavior (stable per-client identity → the desktop reapplies that client's scaling/resolution on reconnect) + what Shared / Per-client / Per-client+resolution each do. - Cap description/help width at max-w-prose so long help text isn't a full-viewport line on large screens. - Mobile bottom nav: 8 flat tabs were too cramped → 4 pinned tabs + a "More" tab whose sheet holds the rest (Performance/Logs/Pairing/Settings), "More" highlighted when the active route is in the overflow. - More breathing room under the "Preset" heading. web tsc + biome + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ac030bbda0 |
feat(vdisplay): ship keep_alive=forever (gaming-rig) — Windows MgrState::Pinned
Completes the last §6A-era preset. The Linux registry already resolved forever→Pinned (pure
lifecycle machine); the blockers were the Windows manager, the mgmt reject, and the console tag:
- Windows manager: new `MgrState::Pinned { mon }` — the last-released monitor under keep_alive=forever
is kept indefinitely (like Lingering but the linger timer never fires). A reconnect preempts +
recreates it (same as Lingering — a reused IddCx swap-chain is dead), snapshot reports "pinned",
and `force_release` (POST /display/release, the §8 escape hatch) frees a pinned monitor. release()
branches on the new `keep_alive_forever()`; all MgrState matches made exhaustive over Pinned.
- mgmt PUT /display/settings: stop rejecting keep_alive=forever (now honored on both platforms with a
release path). OpenAPI regenerated.
- web: un-disable the gaming-rig preset (DISABLED_PRESETS now empty) — one-click applies.
Linux paths + web/tsc/openapi green; 47 vdisplay tests pass. The Windows manager.rs is #[cfg(windows)]
(not compilable on the Linux dev box) — build-verified + on-glass validation on .173 to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
956420d947 |
fix(web): preset cards use the design-system animated Card (motion + material)
The preset options were raw <button>s — flat, no motion/material — unlike the rest of the console. They now render as the `interactive` AnimatedCard (motion hover + specular material, consistent with every other card), keyboard-accessible (role=button + Enter/ Space), with a 2px primary ring for the active one and a proper disabled state for gaming-rig. web tsc + vite build + biome-lint green; deployed on .21. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
42ea12ad8f |
fix(web): unify display-field spacing (shared Field) + clearer layout help
- Every option in the custom form now renders through one `Field` wrapper (label → control → help at a consistent `space-y-3`), so the label→input gap is roomier and identical across keep-alive, the button groups, and max-displays — the first field no longer spaces differently from the rest. - Reworded the multi-monitor layout help: it now says Auto is side-by-side and Manual gives a per-display X/Y editor "in the Live displays section below once two or more are streaming" — instead of pointing at an "arrangement table" that isn't visible until clients connect. web tsc + vite build + biome-lint green; deployed on .21. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5b71a74da5 |
fix(web): explicit keep-alive Off/Keep toggle + roomier custom display form
Two UX fixes on the Virtual displays Configuration card: - Keep-alive is no longer implicitly "on" by typing in the seconds field. It's an explicit two-button toggle — **Off** (tear down at disconnect) vs. **Keep for** [N] seconds — and the seconds input only appears when "Keep for" is selected. The duration is remembered across toggles, and the help text explains both modes. - Opened up the cramped custom form: the fields container is `space-y-6` with more padding (`p-5`, rounded-lg), each option group is `space-y-2.5`, and the Save button sits below a divider — so it reads as sections with room instead of a pressed stack. web tsc + vite build + biome-lint green; deployed on .21. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
167b3ae251 |
feat(web): move Virtual displays to its own nav section; roomier preset grid
The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the virtual-display config surface is large enough to warrant its own home. - New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and `nav_displays` i18n. Removed the displays card from the Host page/view. - On its own page the card splits into two: **Configuration** (presets + custom axes) and **Live displays** (the live list + arrangement table) — room to breathe. - Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned "what it sets" badges so the cards line up. web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |