Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 ff190e9825 fix(web): the logs filter row stops touching the card's edge on desktop
windows-host / package (push) Failing after 28s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 2m13s
ci / docs-site (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 3m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 34s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish (push) Successful in 3m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
deb / build-publish-host (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / deploy-docs (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 3m1s
ci / rust (push) Successful in 6m29s
arch / build-publish (push) Successful in 7m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m42s
Found on glass. The logs card has no CardHeader, so it puts the top padding back
itself — but only at one breakpoint. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`,
and tailwind-merge resolves conflicts only within the same variant: a bare
`pt-6` cancels `pt-0` and leaves `sm:pt-0` standing. Measured on .173: 24px of
top padding at 420px wide, 0px at 1280px, with the level filters and the search
box sitting flush against the card border.

It is the same trap `components/ui/card.tsx` documents for `p-0` — a variant
cancelling its unprefixed counterpart and nothing else — just in the other
direction, so the note now points both ways. This card was the only offender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 dc57aa653c feat(web): the console can say what just happened, and hand a phone the way in
**Recent activity.** The console could describe the present — a status snapshot —
but never the recent past. A client that connected and left while you were on
another page left no trace anywhere you could look, and the host's own log is a
developer artifact rather than a narrative. The event stream was already open for
cache invalidation, so a feed costs one ring buffer next to it: every frame is
recorded, labelled per kind, and rendered newest-first on the dashboard.

Deliberately in-memory and bounded to 200. It starts empty on a page load and
fills as things happen, which is the honest shape for a live tail — an audit
trail would need the host to keep one, and pretending otherwise would be worse
than not having it.

**Connect a device.** The console knew the host's address and identity all along
and never offered either in a form you could hand to a phone: pairing meant
reading an IP off the Host page and retyping it on a couch. There is a card now
with the address and a `punktfunk://connect/<uniqueid>` deep link, both
copyable — the link is the shipped client grammar
(clients/shared/deeplink-vectors.json), so an installed client opens straight
onto this host. No QR: rendering one needs an encoder we do not bundle, and a
wrong QR is worse than none.

**Installable.** A web manifest and the theme/apple meta tags, so the console can
live on a phone's home screen — which is where it is used from as often as from a
desk. No service worker on purpose: an offline shell for a console whose every
screen is live host state would only ever show stale numbers convincingly. The
manifest is reachable without a session (install needs it, and it says nothing
the login page doesn't); /api stays gated, verified.

Verified in a browser: three host-emitted events appear in the feed with the
right labels, the deep link renders and copies as
`punktfunk://connect/abc123`, and the manifest serves 200 as
application/manifest+json while /api/v1/host still answers 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 b8f603c8a1 feat(web): the numbers behind "it's slow to start" and a way to clean up after a plugin
Three things the host already reports and the console never showed.

**Stream diagnostics.** `RuntimeStatus.stream` has carried the session bring-up
time, the last mid-stream resize cost, the client's FEC parity floor and the
packet size for as long as the endpoint has existed, and the dashboard showed
none of them. So "it takes ages to start" and "it hitches when I change
resolution" had no number attached anywhere in the console — you had to take a
stats capture to see a value the status endpoint was already returning. The two
timings are native-plane only and null until the first frame lands, so each
appears once it means something.

**Loss and FEC recovery while the capture runs.** The health chart existed but
only in the saved-recording view, which is backwards: dropped frames and FEC
recovery are what you watch a live capture for. It now sits under the latency and
throughput charts on the live card, keeping the GameStream caveat (only `frames`
is instrumented on that plane).

**Provider-owned library entries.** A plugin can sync entries into the library,
and the host then refuses to edit or delete them one at a time — correct, and
completely opaque once the plugin is gone: its games sit in the library with no
console-side way to remove them. `DELETE /library/provider/{provider}` is the
documented clean-uninstall path and nothing called it. There is a card now that
names each provider, counts what it owns, filters the grid to it, and removes its
entries in one go.

Also: the dashboard's PIN tile really does say "Waiting"/"None" now. The earlier
commit added the strings but the edit that was supposed to use them silently did
not apply, so the tile still rendered a bare "●". Caught by auditing every
message key for a call site — the other 543 are wired.

Verified in a browser: the providers card shows, counts, and filtering hides
non-provider entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 0751265105 fix(web): editing a library entry warns before it wipes what the console cannot see
`PUT /library/custom/{id}` replaces the whole entry — the host assigns
`slot.prep = input.prep` and `slot.detect = input.detect` outright
(library/custom.rs). But `GET /library` returns a `GameEntry`, which carries
neither field, so the console builds its payload from a read model that has
already lost them. Editing a title to fix a typo silently cleared any prep/undo
commands and detection hints the entry had.

The console cannot round-trip what the read API will not tell it, so this is a
warning, not a fix: the edit form now says plainly that saving replaces the entry
and that anything configured outside the console will be cleared. The actual fix
is host-side — expose `detect` and `prep` on the library read model — and is
noted in the code where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 55e01c1460 fix(web): stop shipping 7 MB of sound the console cannot play
`@unom/ui/button` reaches `sound/defaults.js`, which resolves two game-UI sprite
sheets with `new URL(…, import.meta.url)` at module scope — a 4.8 MB .wav and a
2.2 MB .mp3. Vite emitted both into the build, so they rode into the Windows
installer and the .deb. The console never mounts UnomProviders, so no player is
bundled and not one byte of it could ever be played. A build-time rewrite of
those two expressions takes the asset payload from 8.2 MB to 1.5 MB; the login
page and the button chunk are unchanged. Deleting the plugin is the whole revert
if the console ever wants click sounds.

Also:

- `bun run dev` forwards the management bearer, so developing against a real
  host stops 401ing into a /login bounce that dev has no gate to satisfy.
- `check-i18n` runs after `build`, not only inside `codegen`. It exists to stop a
  zero-message console shipping, and the CI job and the installer build both
  install with `--ignore-scripts`, so it had never once run where it mattered.
- Bun's idle timeout goes from its 10 s default to 120 s. The host sends SSE
  keep-alives every 15 s, so anything long-lived proxied through the console was
  cut by us first — which the event stream is about to depend on.
- The typecheck covers the Storybook config and preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 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>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 4575134c21 fix(web): one bad password from anywhere stops locking out the whole console
The console's login throttle was documented as per-IP and was not. Nitro's
`localFetch` hands the app a synthetic request whose socket has no
`remoteAddress`, so `getRequestIP()` returned undefined for every request and
every attempt was charged to one shared "unknown" bucket. Five wrong guesses
from any LAN peer locked out everyone — including the operator, and including
the update-apply route, which shares that budget. The Bun entry is the only
place the real peer is knowable, so it now stamps it into a header (deleting
any client-supplied copy first) and `peerAddress()` reads it back.

Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1
lock 127.0.0.1 out, a different peer still logs in on the first try, and a
request forging the header is charged to its real address.

Also on the way through:

- Installing an unreviewed package and adding a catalog source now re-ask for
  the console password, like applying an update already did. A 7-day session
  cookie should not be able to run new code on the host, and `store/install`
  with `accept_unverified` did exactly that through the generic passthrough.
  The gate sits at the trust boundary — adding a source, or a raw spec — not
  on every install from a source the operator already chose to trust.
- The ui-credential denylist is matched against the normalised path too, so
  `/api//v1/...` and friends can no longer walk around it.
- The console serves nosniff, a no-referrer policy, and a CSP that pins
  frame-ancestors, object-src and base-uri.
- A plugin UI's response no longer re-emits the content-encoding that `fetch`
  already decoded (which made compressed plugin pages fail to load), no longer
  sets cookies on the console's origin, and OPTIONS reaches the plugin instead
  of being refused 405 by us.
- An unreachable host reads as 502 on these routes, matching the passthrough,
  instead of a bare 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00
enricobuehlerandClaude Opus 5 6a4ffcb15c fix(client): HDR stops leaking out of the stream and blowing out the console UI
ci / web (push) Successful in 1m3s
ci / rust-arm64 (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 3m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
deb / build-publish (push) Successful in 5m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 27s
android / android (push) Successful in 8m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m12s
arch / build-publish (push) Successful in 9m35s
flatpak / build-publish (push) Successful in 6m57s
ci / rust (push) Successful in 10m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m17s
apple / swift (push) Successful in 5m10s
apple / screenshots (push) Canceled after 19m1s
The gamepad/console UI looked right on launch, and wrong forever after the first HDR
session: connect to an HDR host, disconnect, and the UI came back overblown with wrong
colours.

The UI is not its own renderer. It is a `pf_presenter::overlay::Overlay` composited into
the SAME swapchain the stream used, and it draws plain sRGB with no HDR awareness at all —
so the swapchain's colorspace decides how its pixels are read. `present` switches SDR↔HDR10
from the FRAME's colour signalling, and a UI-only present is `FrameInput::Redraw`, which
carries none: the mode block is skipped entirely and nothing ever hands HDR10 back. The
UI's sRGB mid-tones were then emitted as PQ code points, i.e. near-peak nits.

`leave_hdr` drops back to SDR, called where the UI-only present already happens and gated
on the existing `browse_idle` — Browse mode with no live connector, i.e. the UI owns the
screen. That covers every route back to the UI rather than just the Ended/Failed arms, and
it is guarded internally so idle iterations stay free.

It also bails when minimized, which is load-bearing rather than an optimization:
`recreate_swapchain` keeps the old swapchain at a zero extent, but `set_hdr_mode` would by
then have rebuilt the CSC and overlay pipes against the SDR format — mismatched against
live HDR10 images. `present` early-returns on a zero extent above its HDR block, so this
was unreachable until a caller outside `present` existed.

Deliberately not applied to the `resize_scrim` arm of the same present: that scrim is a
mid-stream gap in a session that is still HDR, and flipping there would rebuild the
swapchain twice per resize.

Does not address the adjacent case: an overlay drawn DURING a live HDR session (the stats
HUD, the resize scrim) is blown out the same way. That needs the overlay to PQ-encode when
`hdr_active`; dropping to SDR is only correct here because the console UI shows exactly
when no stream is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:08:38 +02:00
enricobuehlerandClaude Opus 5 43a631ea9c fix(clients): a host that re-keys stops locking the client out for good
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m25s
docker / deploy-docs (push) Canceled after 25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 5m20s
arch / build-publish (push) Successful in 6m52s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Canceled after 7m17s
deb / build-publish-host (push) Successful in 5m45s
apple / screenshots (push) Canceled after 1m5s
ci / rust (push) Canceled after 7m24s
flatpak / build-publish (push) Canceled after 3m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m47s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 11s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reinstall a host, wipe its ProgramData, or otherwise regenerate its identity,
and the desktop clients refused it forever: "Host identity rejected — wrong
fingerprint, or the host requires pairing", including immediately after a
successful re-pair. There was no way out of it from the UI — the host list
showed two cards for one address and forgetting the wrong one was a guess.

`KnownHosts::upsert` matches on the FINGERPRINT, which is what lets a host that
moved address keep its record and everything the user set on it. A host that
changed identity matched nothing, so pairing appended a SECOND record for an
address that already had one, and `find_by_addr` returned whichever came first
in the file — the dead one, every time.

Trust decisions (PIN ceremony, delegated approval, TOFU accept, headless pair —
all funnelled through `persist_host`, plus the Windows shell's two direct
upserts) now go through `upsert_trusted`, which retires any OTHER record for
that address. Retired means DELETED, not demoted: a record whose certificate
the host no longer holds cannot connect, so keeping it only reproduces the two-
cards-one-address confusion this fixes. What described the box rather than the
identity — its MAC, its OS chain, the bound profile, the pinned cards, when it
was last used — moves onto the record that survives, so a reinstall doesn't
quietly cost the user their setup. What described the dead identity does not:
`paired` and `clipboard_sync` are decisions about one specific certificate and
have to be made again for a new one, and the retired record's stable id stays
retired (a deep link written from it falls through to the `host=` recovery the
link grammar already specifies).

Only trust decisions may retire a record. The wake path's address re-key and
every learn-from-advert path stay on plain `upsert`: those are driven by
unauthenticated mDNS, and letting an advert delete a saved host by claiming its
address would trade this bug for a much worse one. A plain reconnect still
fails closed on a pin mismatch — nothing here changes what the pin is checked
against.

Stores that already hold the duplicate recover on the next connect, not at
load: which of two records is live isn't knowable at load time and guessing
wrong would throw away the good one. Instead `find_by_addr` stops being
positional — a real fingerprint beats a placeholder, and among real ones the
newest trust decision wins, since records are only ever appended by one. The
next successful pair then cleans the store up for good. Every lookup that picks
a pin or a per-host decision for a connect now goes through it (the session's
pin and clipboard read, the deep-link resolver, orchestrate's plan, both speed
tests, the CLI's --wake and --library, which had also been ignoring the port),
and an advert's learned MAC/OS lands on the record it identified rather than on
a stale namesake that merely came first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:00:49 +02:00
enricobuehlerandClaude Opus 5 6af067da2d fix(client): the bottom rows stop smearing — the decode pool is taller than the picture
ci / web (push) Successful in 1m49s
ci / rust-arm64 (push) Successful in 2m19s
ci / docs-site (push) Successful in 2m29s
ci / rust (push) Successful in 7m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish (push) Successful in 4m17s
deb / build-publish-host (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m28s
flatpak / build-publish (push) Successful in 6m8s
apple / swift (push) Successful in 5m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 13m5s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m10s
apple / screenshots (push) Canceled after 7m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m57s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 5m3s
A user's 1080p stream repeated its last row of pixels over the final few rows, so the
image looked stretched at the bottom.

The Vulkan-Video CSC pass sampled the decoded planes with the fullscreen triangle's
normalized 0..1 UVs, but its render target is built at the CROPPED frame size. Those are
not the same rectangle: FFmpeg sizes the decode pool from `avctx->coded_*`, and H.264
codes `16 * mb_height` — so a 1080-row picture decodes into a 1088-row pool. Destination
row 1079 sampled source row ~1087.5, dragging the 8 alignment rows into view and squashing
the picture 0.7%. Encoders fill that padding by replicating the last picture line, which
is why it reads as a smeared bottom row rather than garbage.

Confirmed on glass (.173, RTX, H.264 1080p, vulkan-video):
  Vulkan Video first frame width=1920 height=1080 pool_w=1920 pool_h=1088

`VkVideoFrame` now carries the pool extent and `record_csc` takes a `uv_scale`, written to
the shader's `params.zw` — which the CSC shader already reserved for a use like this. The
chroma cositing offset is unchanged and stays correct: `textureSize` reports the pool
width, which is the space the scaled UV is already in.

Only the Vulkan-Video path passes a scale below 1.0. D3D11VA already clamps this in its
VideoProcessor blit (the same bug, seen as a green bar there because DXVA padding is
uninitialized rather than replicated); dmabuf imports its planes at the crop over the real
stride; PyroWave allocates its ring at exact stream dims. Apple and Android crop at the OS
layer. Every other call site passes [1.0, 1.0], so the change is inert there.

Also adds a one-time first-frame layout log mirroring the D3D11VA one, so the frame-vs-pool
gap is visible in the field instead of having to be re-derived from FFmpeg internals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:40:33 +02:00
enricobuehlerandClaude Fable 5 9f72a3b6ad feat(host): HDR and 4:4:4 stop being mutually exclusive on Windows
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
deb / build-publish (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 33s
deb / build-publish-host (push) Successful in 5m4s
android / android (push) Successful in 6m40s
ci / rust (push) Successful in 7m10s
arch / build-publish (push) Successful in 8m11s
apple / swift (push) Successful in 5m8s
apple / screenshots (push) Canceled after 10m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
An HDR display cost you full chroma: the IDD-push capturer's only 10-bit
output was P010, so a session that negotiated 4:4:4 on an HDR desktop was
converted to 4:2:0 at capture time — *after* the Welcome had already told
the client 4:4:4. The client believed it (nothing on the wire contradicts
a Welcome), and the new chroma tag in the stats overlay is what finally
made the discrepancy visible.

Everything except the source was already in place, which is why this is
small: the NVENC config layer has stamped `FREXT` + `chromaFormatIDC=3` +
`pixelBitDepthMinus8=2` — HEVC Main 4:4:4 10 — with a unit test since the
4:4:4 work landed, `PixelFormat::Rgb10a2` already maps to `ABGR10` and
already counts as a full-chroma input, and the desktop client learned the
10-bit 4:4:4 Vulkan pool format in 74863c96. The one missing piece was a
capture format that keeps 10 bits AND full chroma.

`HdrRgb10Converter` is that piece: one full-res pass from the FP16 scRGB
desktop to packed `R10G10B10A2` in BT.2020 PQ, reusing the P010 shader's
`scrgb_to_pq2020` verbatim so both HDR outputs share bit-identical colour
math — it simply stops before the RGB→YUV matrix, the studio-range
squeeze and the chroma decimation. NVENC then does the CSC to YUV 4:4:4
itself under FREXT, exactly as the SDR BGRA passthrough has always done
at 8 bits.

No swizzle is involved and that is worth stating, because it looks like
it should be: NVENC names packed formats from the MSB down, so its
`ABGR10` (A2B10G10R10) puts R in the low 10 bits — bit-identical to DXGI
`R10G10B10A2_UNORM`. It is the same relationship the proven SDR pair
relies on between DXGI `B8G8R8A8` and NVENC's `ARGB`.

The honesty gap closes as a consequence: `capturer_supports_444` no
longer has a depth it cannot serve, so the chroma resolved before the
Welcome is the chroma the wire carries. AV1 is deliberately untouched —
Range Extensions are HEVC-only and no consumer encoder does AV1 4:4:4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:06:44 +02:00
enricobuehlerandClaude Fable 5 47eb8c9f6f fix(windows): a web-console deploy that half-succeeded stops reporting success
windows-host / package (push) Failing after 26s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m57s
ci / docs-site (push) Successful in 2m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-client-arm64 (push) Successful in 2m22s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
deb / build-publish-host (push) Successful in 4m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
deb / build-publish (push) Successful in 5m45s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 31s
android / android (push) Successful in 7m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m55s
arch / build-publish (push) Successful in 8m58s
ci / rust (push) Successful in 7m17s
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
release / apple (push) Successful in 16m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m53s
apple / screenshots (push) Canceled after 16m31s
Found by investigating a console that had been serving errors on .173 for
hours while every health check said HTTP 200.

Nitro's `entry.mjs` imports its sibling chunks by CONTENT HASH, so a
`.output` that mixes two builds is not degraded — it is dead: bun answers
every page with a `ResolveMessage` JSON body ("Cannot find module
../_/router-<hash>.mjs") under a 200 status. Two defects here let exactly
that ship and then hid it:

* The pre-copy `Remove-Item` used `-ErrorAction SilentlyContinue`, so a
  removal blocked by a still-running bun was swallowed and `Copy-Item`
  merged the new build into the old tree. (Reproduced live: an older
  task-based copy of this script, run against the now supervised-child
  host, tried `schtasks /end` for a task that no longer exists, never
  stopped the service, and so could never unlock the files.) The removal
  is now verified and refuses to copy over a tree it could not clear.
* The success probe read only the status code, so it reported a healthy
  console for a server that serves nothing but an error. It now checks
  that `/login` actually returns HTML, and says so loudly when the body
  is a module-resolution error instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:40:26 +02:00
enricobuehlerandClaude Fable 5 2ce0bea830 feat(client): desktop phase-locked capture, a real display volume on Windows, and the console's missing rows
The cross-platform half of the gap sweep:

* Phase-locked capture reaches the DESKTOP clients (it shipped on
  Apple/Android only, though the desktop presenter has the best latch
  signal of all — true on-glass stamps via VK_KHR_present_wait). The
  presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass
  instant; period = min positive present spacing, capped by the display
  mode's refresh so an arrival-paced sub-panel-rate stream can't claim a
  slower grid); the session pump folds every AU's arrival stamp against
  it with the SHARED `phase::circular_latch` statistic and sends the
  ~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The
  cap is advertised only when present timing is real
  (`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`),
  and the host's applied grid offset from the 0xCF tail is logged so an
  on-glass run can watch the controller engage.
* `Hello::display_hdr` stops being hardcoded `None`: Windows reads the
  panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the
  `--window-pos` output else the primary, advanced-color outputs only,
  gated on the HDR setting) so the host's virtual-display EDID matches
  the real glass. Linux keeps the EDID defaults — no portable
  Wayland/X11 query exists — and the comment now says exactly that.
* The console settings screen (the ONLY editor in Gaming Mode) learns
  the rows it was missing: render scale, full chroma 4:4:4, invert
  scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and
  the game-library toggle.
* A spec-run session's device picks (GPU adapter, speaker, microphone)
  now come from the `--resolved-spec` instead of a raw Settings load —
  the last store read the spec path still owed (§5), and what would
  make those fields profileable.
* `session_args()` documents why the GTK/CLI spawners pass no
  `--window-pos` (Wayland exposes no global coordinates to read and SDL
  can't apply them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:05:24 +02:00
enricobuehlerandClaude Fable 5 fd75e66041 feat(windows): the shell spawns from the shared brain, and links, tests and pairing stop losing things
Windows-shell parity gaps from the 2026-07-31 sweep, closed:

* The spawner goes through the shared brain: `ConnectPlan::for_target` →
  `session_args()` + a written `--resolved-spec`, replacing the
  hand-assembled argv that meant Windows sessions ALWAYS took the compat
  path (re-resolving every setting from the stores — the drift
  orchestrate.rs documents as a trap) and that any field added to the
  spec was silently Windows-dead. Fullscreen now comes from the plan's
  effective settings (profile-aware) instead of a caller argument, the
  spec temp file is cleaned up at exit, and the reader finally parses
  the `{"window":…}` line so the SPAWNER persists the match-window size
  (§5) instead of the renderer's load-modify-save fallback.
* A deep link to an unpaired host runs the trust ceremony instead of
  refusing: the Pair screen opens seeded with what the link CLAIMED —
  name shown as claimed, fingerprint carried, and the launch/profile
  surviving the detour (`Target` grew a `launch` field for exactly
  this). GTK parity; a shared link was a dead end here before.
* The speed test learns the Ask tier: a bound host whose profile
  INHERITS bitrate now offers both "Set as default" and "Set in
  profile" instead of silently creating a profile override. The global
  write (and the forwarded-controller handler) also rebase on the file
  before their whole-struct saves — two more stale-snapshot writers.
* The Controllers card shows the detected-pad inventory (read-only,
  with the Steam-Input-virtual note) — the fastest answer to "is my
  controller even detected?", present on GTK all along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:05:05 +02:00
enricobuehlerandClaude Fable 5 698925a036 fix(clients): settings saves stop reverting each other, Windows gets audio pickers, and a game link launches its game
The rest of the client-gaps handoff, plus what a parity sweep surfaced:

* Every whole-file settings save rebases on the file first — the GTK
  dialog close and its two speed-test apply arms, the Windows
  per-control commit, the console screen. The file has five writers and
  no merge (profiles.rs documents the debt); saving a shell-lifetime
  snapshot silently reverted whatever another writer stored meanwhile,
  most visibly the spawner-persisted match-window size (item 4).
* Windows honors the Speaker/Microphone picks: `audio_wasapi` grows
  endpoint enumeration (on its own MTA thread — UI threads are STA) and
  resolves PUNKTFUNK_AUDIO_SINK/SOURCE as endpoint ids, falling back to
  the default when the picked device is gone; the settings page gets
  the two rows (defaults scope, "(not detected)" like the GPU row).
  `speaker_device`/`mic_device` stop being Linux-only fields (item 5).
* A punktfunk:// link with `launch=` opened a plain desktop session on
  Windows — the id was parsed, validated, planned, and dropped at the
  last hop. It now rides `initiate_launch` (plus a waking variant for
  the dial-first path). Linux always forwarded it.
* The console UI offered "VAAPI" on Windows — a dead option there that
  also hid d3d11va, the actual Windows hardware path. The decoder list
  is per-OS now.
* The Windows gamepad picker learns "Steam Deck" (the GTK picker had
  it; the host-side pad has always existed).
* Doc fixes: gpu.rs named a nonexistent env var (PUNKTFUNK_ADAPTER →
  PUNKTFUNK_VK_ADAPTER), and session/Cargo.toml claimed video decode is
  Linux-only while explaining what the ARM64 leg drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:26 +02:00
enricobuehlerandClaude Fable 5 74863c96b3 feat(client): 4:4:4 can stay on hardware, and the overlay says what you actually got
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).

The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.

Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:

The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (9c5af8d7) ship four releases — measured goodput
alone cannot distinguish "the encoder is capped at 20" from "my
200 Mb/s grant met a cheap scene".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:10 +02:00
111 changed files with 5339 additions and 703 deletions
+8 -3
View File
@@ -749,7 +749,12 @@ impl AppModel {
dialog.connect_response(Some("apply"), move |_, _| {
let where_to = match &target {
SpeedTestTarget::Global => {
// Rebase on the file before the whole-file save (same
// discipline as the settings dialog): another writer — the
// spawner's window-size persist, a second window's dialog —
// may have moved it under this shell's snapshot.
let mut s = settings.borrow_mut();
*s = Settings::load();
s.bitrate_kbps = recommended_kbps;
s.save();
"the default bitrate".to_string()
@@ -765,7 +770,9 @@ impl AppModel {
});
}
dialog.connect_response(Some("apply-global"), move |_, _| {
// Rebase on the file first — see the Global arm above.
let mut s = settings.borrow_mut();
*s = Settings::load();
s.bitrate_kbps = recommended_kbps;
s.save();
toasts.add_toast(adw::Toast::new(&format!(
@@ -796,9 +803,7 @@ impl SpeedTestTarget {
// Resolved exactly the way a connect resolves it: the one-off pick this test was
// started with (a pinned card carries one), else the host's binding.
let bound = trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == req.addr && h.port == req.port)
.find_by_addr(&req.addr, req.port)
.and_then(|h| h.profile_id.clone());
let reference = match req.profile.as_deref() {
Some("") => return SpeedTestTarget::Global,
+4 -9
View File
@@ -164,9 +164,7 @@ pub fn cli_wake() -> glib::ExitCode {
let (addr, port) = parse_host_port(&target);
let port = port.unwrap_or(9777);
let mac = crate::trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port)
.find_by_addr(&addr, port)
.map(|h| h.mac.clone())
.unwrap_or_default();
if mac.is_empty() {
@@ -201,9 +199,7 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
.and_then(crate::trust::parse_hex32)
.or_else(|| {
crate::trust::KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == addr)
.find_by_addr(&addr, port)
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
});
match crate::library::fetch_games(&addr, port, &identity, pin) {
@@ -343,9 +339,8 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
// No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
let mut known = KnownHosts::load();
if let Some(h) = known
.hosts
.iter_mut()
.find(|h| h.addr == addr && h.port == port)
.index_by_addr(&addr, port)
.and_then(|i| known.hosts.get_mut(i))
{
h.name = name;
} else {
+7
View File
@@ -1910,7 +1910,14 @@ pub fn show_scoped(
commit_profile(active, &touched, &values);
}
None => {
// Rebase on the file, not the shell's start-of-app snapshot: the settings file
// has other whole-file writers (the spawner persists `last_window_w/h` after a
// match-window resize — see `profiles.rs` on why there's no merge), and saving
// the stale snapshot here would silently revert whatever they stored while this
// app was open. The rows carry every value this dialog owns, so applying them
// onto a fresh load loses nothing.
let mut s = settings.borrow_mut();
*s = Settings::load();
apply_rows(&mut s);
s.save();
}
+2 -1
View File
@@ -17,7 +17,8 @@ default = ["ui", "pyrowave"]
# PyroWave client decode (the wired-LAN wavelet codec) — enables the decode backend + the
# planar present path. ON by default; each session still opts in explicitly (the Settings
# codec pick, or PUNKTFUNK_PREFER_PYROWAVE=1). The Windows ARM64 leg builds
# --no-default-features and so skips it (video decode is Linux-only anyway).
# --no-default-features and so skips it — note that also drops `ui` (the Skia console/OSD),
# not just pyrowave; hardware video decode itself (D3D11VA / Vulkan Video) is unaffected.
pyrowave = ["pf-client-core/pyrowave", "pf-presenter/pyrowave"]
# The Skia console UI (stats OSD, capture HUD, later the gamepad library). Dropping it
# (`--no-default-features`) is the ~15 MB-smaller power-user build: same streaming,
+2 -3
View File
@@ -448,9 +448,8 @@ impl ServiceState {
// Manual entries have no fingerprint yet, so `upsert` (fp-keyed) would
// collide two of them — key manual saves by address instead.
if let Some(h) = known
.hosts
.iter_mut()
.find(|h| h.addr == addr && h.port == port)
.index_by_addr(&addr, port)
.and_then(|i| known.hosts.get_mut(i))
{
if !name.is_empty() {
h.name = name;
+49 -17
View File
@@ -175,10 +175,11 @@ mod session_main {
// the last store read the compat path still owes. `addr` is moved into the struct
// below, so read it first.
let clipboard = clipboard_override.unwrap_or_else(|| {
// The record this address RESOLVES to, not "any record mentioning it": a retired
// duplicate must never be the one that hands a host the clipboard.
trust::KnownHosts::load()
.hosts
.iter()
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync)
.find_by_addr(&addr, port)
.is_some_and(|h| h.clipboard_sync)
});
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
// key) to OUR gamepad service — the shells' in-process services can't reach this
@@ -218,6 +219,8 @@ mod session_main {
height: sh,
..mode
};
// Before the struct literal — `vulkan` moves into it below.
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
SessionParams {
host: addr,
port,
@@ -248,9 +251,14 @@ mod session_main {
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
// Welcome BEFORE we build a decoder. Advertised unconditionally when the user asks
// for it because every decode path here can produce it: the hardware ones where the
// driver decodes RExt, and swscale for the rest (the decoder demotes on its own).
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
// formats (hardware RExt decode where the driver offers it — NVIDIA today) and
// swscale converts anything else for the software rung, with the decoder ladder
// demoting on its own. No capability probe gates the bit — software decode is the
// guaranteed floor — but the cost is VISIBLE, not silent: the Detailed stats
// overlay prints the resolved chroma ("4:4:4→4:2:0" when the host declined) and
// the decode path frames actually took.
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
| if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
@@ -262,9 +270,19 @@ mod session_main {
} else {
0
},
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
// This panel's HDR colour volume → the host's virtual-display EDID, so host
// apps tone-map to the real glass. Windows reads it from DXGI (the
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
// setting, since with 10-bit/HDR unadvertised above the volume is noise. No
// portable Wayland/X11 query exists yet, so Linux keeps the host's EDID
// defaults; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session pump) pins one
// manually on either OS and wins over both.
#[cfg(windows)]
display_hdr: settings
.hdr_enabled
.then(|| pf_client_core::video_d3d11::display_hdr_volume(window_pos()))
.flatten(),
#[cfg(not(windows))]
display_hdr: None,
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
// channel); capture-mode sessions keep the composited cursor, so only advertise
@@ -284,6 +302,13 @@ mod session_main {
connect_timeout: connect_timeout(),
force_software,
profile,
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
// advertised only when the presenter has real on-glass latch stamps
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
// grid itself is written by the presenter (run_session clones the Arc out of
// these params) and folded into ~1 Hz PhaseReports by the session pump.
phase_lock,
latch_grid: std::sync::Arc::new(pf_client_core::session::LatchGrid::default()),
}
}
@@ -438,11 +463,21 @@ mod session_main {
// The Settings device picks → env, unless the user already forced one by hand:
// the GPU (the shells' pickers store the adapter's marketing name) for the
// presenter's device selection, and the audio endpoints (PipeWire node names)
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
// the RADV knob (covers --connect and --browse).
// presenter's device selection, and the audio endpoints (PipeWire node names /
// WASAPI endpoint ids) for the playback/mic streams. Before any Vulkan call,
// like the RADV knob (covers --connect and --browse).
//
// Spec mode takes them from the SPEC's settings — the spawner's resolve — which
// keeps the §5 zero-store-reads invariant and lets a profile overlay reach these
// fields if they ever become profileable. Parsed leniently here (the `--connect`
// flow re-reads the spec authoritatively and errors there); the compat path and
// `--browse` (which never carries a spec) still load the store.
{
let s = trust::Settings::load();
let s = arg_value("--resolved-spec")
.and_then(|p| {
pf_client_core::orchestrate::ResolvedSpec::read(std::path::Path::new(&p)).ok()
})
.map_or_else(trust::Settings::load, |spec| spec.settings);
for (var, value) in [
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
@@ -540,10 +575,7 @@ mod session_main {
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
// silent TOFU would defeat the pinning model. Pair via the desktop client.
let known = trust::KnownHosts::load();
let known_host = known
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port);
let known_host = known.find_by_addr(&addr, port);
let pin = arg_value("--fp")
.as_deref()
.and_then(trust::parse_hex32)
+40 -15
View File
@@ -80,14 +80,42 @@ fn initiate_opts(
}
/// Start a stream that launches a library title on connect (`--launch id`) — the library
/// page's tap-to-play. The library only opens for paired hosts, so the pin resolves like
/// a normal initiate; a host forgotten mid-visit routes to the PIN ceremony instead.
/// page's tap-to-play, and a deep link's `launch=` (which this used to drop: the link
/// opened a plain desktop session). The library only opens for paired hosts, so the pin
/// resolves like a normal initiate; a host forgotten mid-visit routes to the PIN ceremony
/// instead.
pub(crate) fn initiate_launch(
ctx: &Arc<AppCtx>,
target: Target,
launch: String,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
initiate_launch_opts(ctx, target, launch, set_screen, set_status, false)
}
/// [`initiate_launch`] with the dial-first wake of [`initiate_waking`] — a deep link's
/// `launch=` toward a saved host that isn't advertising but has a known MAC.
pub(crate) fn initiate_launch_waking(
ctx: &Arc<AppCtx>,
target: Target,
launch: String,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
if ctx.settings.lock().unwrap().auto_wake {
crate::wol::wake(&target.mac, target.addr.parse().ok());
}
initiate_launch_opts(ctx, target, launch, set_screen, set_status, true)
}
fn initiate_launch_opts(
ctx: &Arc<AppCtx>,
target: Target,
launch: String,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
wake_on_fail: bool,
) {
*ctx.shared.target.lock().unwrap() = target.clone();
let known = KnownHosts::load();
@@ -112,6 +140,7 @@ pub(crate) fn initiate_launch(
set_status,
ConnectOpts {
launch: Some(launch),
wake_on_fail,
..ConnectOpts::default()
},
);
@@ -222,16 +251,6 @@ fn connect_spawn(
*ctx.shared.session.lock().unwrap() = child.clone();
ctx.shared.stats_line.lock().unwrap().clear();
ctx.shared.browse.store(false, Ordering::SeqCst);
// Through the same resolver the session uses, not the raw globals: "Start streams
// fullscreen" is a profileable (tier-P) field, so a host bound to a windowed profile has to
// win here too — the child takes this decision from the argv, not from its own settings.
let fullscreen = pf_client_core::trust::effective_settings(
&target.addr,
target.port,
target.profile.as_deref(),
)
.0
.fullscreen_on_stream;
set_status.call(String::new());
set_screen.call(if opts.awaiting_approval {
Screen::RequestAccess
@@ -249,13 +268,15 @@ fn connect_spawn(
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
let profile_arg = target.profile.clone();
// The launch id: an explicit opts pick (the library's tap-to-play), else one riding
// the target — a deep link's `launch=` that detoured through the PIN ceremony.
let launch_arg = opts.launch.clone().or_else(|| target.launch.clone());
let spawned = crate::spawn::spawn_session(
&addr,
port,
&fp_arg,
opts.connect_timeout.as_secs(),
fullscreen,
opts.launch.as_deref(),
launch_arg.as_deref(),
profile_arg.as_deref(),
child,
move |event| {
@@ -277,8 +298,10 @@ fn connect_spawn(
// host PAIRED so future connects are silent. Plain TOFU persists
// it *unpaired* (pinned): the child connected pinned to the
// advertised fingerprint, so ready proves the host holds it.
// Either way an authorised decision, so `upsert_trusted`: a dead
// record for this address is retired instead of shadowing this one.
let mut k = KnownHosts::load();
k.upsert(KnownHost {
k.upsert_trusted(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
@@ -502,6 +525,8 @@ fn wake_and_connect(
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved
// host so the pin stays reachable next time (keyed by fingerprint;
// addr/port overwritten, `paired`/`mac` preserved by `upsert`).
// Plain `upsert` on purpose — this is an mDNS advert talking, not a trust
// decision, so it may never retire another saved host at that address.
if let Some((addr, port)) =
resolved.filter(|(a, p)| *a != target.addr || *p != target.port)
{
+3
View File
@@ -670,6 +670,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
pair_optional: false,
mac: k.mac.clone(),
profile: None,
launch: None,
};
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
// covers a routed/Tailscale host that never advertises — the display companion to
@@ -959,6 +960,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
pair_optional: h.pair == "optional",
mac: h.mac.clone(),
profile: None,
launch: None,
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" {
@@ -1052,6 +1054,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
pair_optional: false,
mac: Vec::new(),
profile: None,
launch: None,
},
&ss,
&st,
+48 -12
View File
@@ -107,6 +107,10 @@ pub(crate) struct Target {
/// `None` honors the binding. It never rebinds anything — the default changes only through
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
pub(crate) profile: Option<String>,
/// A library title id (`steam:570`, …) to launch on connect — carried on the target so it
/// survives a detour through the PIN ceremony (a deep link's `launch=` toward an unpaired
/// host must still launch the game once pairing succeeds).
pub(crate) launch: Option<String>,
}
/// Stable app services handed to the page components as props. Each routed screen that uses
@@ -392,22 +396,54 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
pair_optional: false,
mac: p.host.mac.clone(),
profile: p.profile_override.clone(),
launch: None, // routed explicitly below (initiate_launch*)
};
// With a MAC it takes the dial first wake path, so a sleeping host wakes
// instead of erroring — exactly what clicking its tile would do.
if p.wake && !target.mac.is_empty() {
connect::initiate_waking(&ctx, target, &set_screen, &set_status);
} else {
connect::initiate(&ctx, target, &set_screen, &set_status);
// instead of erroring — exactly what clicking its tile would do. The
// link's `launch=` id must reach the session (`--launch`) — this arm used
// to drop it, so a game link opened a plain desktop session.
match (p.launch.clone(), p.wake && !target.mac.is_empty()) {
(Some(id), true) => {
connect::initiate_launch_waking(
&ctx,
target,
id,
&set_screen,
&set_status,
);
}
(Some(id), false) => {
connect::initiate_launch(&ctx, target, id, &set_screen, &set_status);
}
(None, true) => {
connect::initiate_waking(&ctx, target, &set_screen, &set_status)
}
(None, false) => connect::initiate(&ctx, target, &set_screen, &set_status),
}
}
// Known but never pinned, or not known at all: a link may not pair or trust on
// its own, so it lands on the host list with the reason shown. The user pairs
// there, under their own eyes.
Ok(PlanOutcome::ConfirmUnknown(u)) => refuse(format!(
"{} isn't paired with this device yet \u{2014} pair it, then use the link again.",
u.name.clone().unwrap_or_else(|| u.addr.clone())
)),
// Known but never pinned, or not known at all: a link may not pair and may not
// trust on its own, so it opens the ordinary PIN ceremony seeded with what the
// link CLAIMED — name shown as claimed, the fingerprint pre-filling the pin so
// the first connect is verified against it rather than blind TOFU, and the
// launch/profile surviving the detour (§3.1; GTK-shell parity — this used to
// refuse outright and make shared links a dead end on Windows).
Ok(PlanOutcome::ConfirmUnknown(u)) => {
let name = u.name.clone().unwrap_or_else(|| u.addr.clone());
*ctx.shared.target.lock().unwrap() = Target {
name: name.clone(),
addr: u.addr.clone(),
port: u.port,
fp_hex: u.fp.clone(),
pair_optional: false,
mac: Vec::new(),
profile: u.profile.clone(),
launch: u.launch.clone(),
};
set_status.call(format!(
"{name} isn't paired with this device yet \u{2014} pair it to continue."
));
set_screen.call(Screen::Pair);
}
Ok(PlanOutcome::Unsupported(route)) => refuse(format!(
"Punktfunk can't open \u{201c}{}\u{201d} links yet.",
route.as_str()
+3 -1
View File
@@ -50,8 +50,10 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
std::time::Duration::from_secs(90),
) {
Ok(fp) => {
// The PIN ceremony is an authorised trust decision, so this also
// retires a dead record for the same address (a re-keyed host).
let mut k = KnownHosts::load();
k.upsert(KnownHost {
k.upsert_trusted(KnownHost {
name: target3.name.clone(),
addr: target3.addr.clone(),
port: target3.port,
+116 -7
View File
@@ -75,6 +75,9 @@ const GAMEPADS: &[(&str, &str)] = &[
("dualsense", "DualSense"),
("xboxone", "Xbox One"),
("dualshock4", "DualShock 4"),
// Kept in lockstep with the GTK picker: this row was missing here, so a Windows
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
("steamdeck", "Steam Deck"),
];
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
@@ -393,7 +396,15 @@ fn commit(
edit: impl FnOnce(&mut Settings),
) {
if scope.is_empty() {
// Rebase on the file before the whole-struct save: the process-lifetime snapshot
// in `ctx.settings` is not the only writer — a spawned session persists its
// match-window size, the console's own settings screen saves too — and saving the
// stale snapshot would silently revert whatever they stored (the same
// load-modify-save family as the GTK dialog's 2026-07-31 fix; profiles.rs
// documents why there's no merge). The edit lands on the fresh load, and the
// snapshot follows so every row keeps rendering what's on disk.
let mut s = ctx.settings.lock().unwrap();
*s = Settings::load();
edit(&mut s);
s.save();
rev.1.call(rev.0 + 1);
@@ -875,9 +886,12 @@ pub(crate) fn settings_page(
keys.get(sel - 1).cloned()
};
// Apply live to the gamepad service and persist — the spawned session
// reads `forward_pad` at connect.
// reads `forward_pad` at connect. Rebase on the file first (the same
// discipline as `commit()`): this handler bypasses commit and a stale
// whole-struct save would revert other writers.
svc.set_pinned(key.clone());
let mut s = ctx2.settings.lock().unwrap();
*s = Settings::load();
s.forward_pad = key.unwrap_or_default();
s.save();
})
@@ -913,6 +927,33 @@ pub(crate) fn settings_page(
let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| {
s.mic_enabled = on
});
// Endpoint pickers (the WASAPI probe — the GTK client's PipeWire twins): visible
// labels are friendly names, the stored value is the endpoint id. Hidden when the
// probe found at most the default; a saved device that's gone keeps a revertable
// "(not detected)" entry, like the GPU row. Device facts — defaults scope only.
let (speakers, mics) = pf_client_core::audio::devices().unwrap_or_default();
let dev_combo = |saved: &str,
devs: &[pf_client_core::audio::AudioDevice],
apply: fn(&mut Settings, String)| {
let mut names = vec!["System default".to_string()];
let mut keys = vec![String::new()];
for d in devs {
names.push(d.description.clone());
keys.push(d.name.clone());
}
if !saved.is_empty() && !keys.iter().any(|k| k == saved) {
names.push(format!("{saved} (not detected)"));
keys.push(saved.to_string());
}
(keys.len() > 1).then(|| {
let current = keys.iter().position(|k| k == saved).unwrap_or(0);
setting_combo(ctx, scope, (rev, set_rev), names, current, move |s, i| {
apply(s, keys[i.min(keys.len() - 1)].clone());
})
})
};
let speaker_combo = dev_combo(&s.speaker_device, &speakers, |s, v| s.speaker_device = v);
let mic_dev_combo = dev_combo(&s.mic_device, &mics, |s, v| s.mic_device = v);
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
let hud_combo = setting_combo(ctx, scope, (rev, set_rev), hud_names, hud_i, |s, i| {
@@ -1134,6 +1175,46 @@ pub(crate) fn settings_page(
group(
None,
[
// The read-only pad inventory (GTK parity): what THIS device sees right
// now — the fastest answer to "is my controller even detected?". A
// device fact, so defaults scope only, like the forward picker below.
(!profile_mode).then(|| {
let inventory: Element = if pads.is_empty() {
text_block("No controllers detected")
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.into()
} else {
vstack(
pads.iter()
.map(|p| {
let sub = if p.steam_virtual {
"Steam Input's virtual pad \u{2014} Automatic skips \
it while a real pad is connected"
.to_string()
} else {
p.kind_label().to_string()
};
vstack((
text_block(p.name.clone()).semibold(),
text_block(sub)
.font_size(11.0)
.foreground(ThemeRef::SecondaryText),
))
.spacing(1.0)
.into()
})
.collect::<Vec<Element>>(),
)
.spacing(8.0)
.into()
};
described_labeled(
"Detected controllers",
inventory,
"Plug in or pair a controller and it appears here.",
)
}),
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
// Which physical pad this device forwards is a device fact (tier G), so it
@@ -1168,8 +1249,8 @@ pub(crate) fn settings_page(
"Audio",
group(
None,
vec![
described_overridable(
[
Some(described_overridable(
(rev, set_rev),
scope,
"audio_channels",
@@ -1178,8 +1259,22 @@ pub(crate) fn settings_page(
channels_combo,
"The speaker layout requested from the host. It downmixes if its own \
output has fewer channels.",
),
described_overridable(
)),
// The endpoint picks are facts about THIS device's hardware — never
// per profile, like Decoder/GPU.
(!profile_mode)
.then(|| {
speaker_combo.map(|c| {
described_labeled(
"Speaker",
c,
"Host audio plays here \u{2014} System default follows \
the Windows output device.",
)
})
})
.flatten(),
Some(described_overridable(
(rev, set_rev),
scope,
"mic_enabled",
@@ -1187,8 +1282,22 @@ pub(crate) fn settings_page(
over.mic_enabled,
mic_toggle,
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
),
],
)),
(!profile_mode)
.then(|| {
mic_dev_combo.map(|c| {
described_labeled(
"Microphone",
c,
"The input that feeds the host\u{2019}s virtual mic.",
)
})
})
.flatten(),
]
.into_iter()
.flatten()
.collect(),
Some("Applies from the next session."),
),
),
+74 -40
View File
@@ -130,9 +130,7 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
// it: the one-off this test was started with, else the host's binding.
let target = ctx.shared.target.lock().unwrap().clone();
let bound = KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == target.addr && h.port == target.port)
.find_by_addr(&target.addr, target.port)
.and_then(|h| h.profile_id.clone());
let profile = match target.profile.as_deref() {
Some("") => None,
@@ -140,39 +138,80 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
None => bound,
}
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
let apply_btn = {
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
let profile = profile.clone();
button(match &profile {
Some(p) => format!(
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
p.name
),
None => format!("Use {recommended_mbps:.0} Mb/s"),
})
.accent()
.icon(Symbol::Accept)
.on_click(move || {
match &profile {
Some(p) => {
let mut catalog = ProfilesFile::load();
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
slot.overrides.bitrate_kbps = Some(kbps);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"),
"saving the measured bitrate");
}
}
}
None => {
let mut s = ctx.settings.lock().unwrap();
s.bitrate_kbps = kbps;
s.save();
let kbps = *recommended_kbps;
let write_global = {
let (ctx, ss) = (ctx.clone(), set_screen.clone());
move || {
// Rebase on the file before the whole-struct save — same discipline as
// `commit()`; another writer may have moved it under this snapshot.
let mut s = ctx.settings.lock().unwrap();
*s = crate::trust::Settings::load();
s.bitrate_kbps = kbps;
s.save();
ss.call(Screen::Hosts);
}
};
let write_profile = |id: String| {
let ss = set_screen.clone();
move || {
let mut catalog = ProfilesFile::load();
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == id) {
slot.overrides.bitrate_kbps = Some(kbps);
if let Err(e) = catalog.save() {
tracing::warn!(error = %format!("{e:#}"),
"saving the measured bitrate");
}
}
ss.call(Screen::Hosts);
})
}
};
// Which button(s): no binding → the global; a binding that already overrides
// bitrate → that override (it's what this host reads). Bound but INHERITING
// bitrate could legitimately mean either layer — offer both rather than
// guessing (the GTK client's Ask tier; this shell used to silently CREATE an
// override on the profile).
let mut buttons: Vec<Element> = Vec::new();
match &profile {
None => buttons.push(
button(format!("Use {recommended_mbps:.0} Mb/s"))
.accent()
.icon(Symbol::Accept)
.on_click(write_global.clone())
.into(),
),
Some(p) if p.overrides.bitrate_kbps.is_some() => buttons.push(
button(format!(
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
p.name
))
.accent()
.icon(Symbol::Accept)
.on_click(write_profile(p.id.clone()))
.into(),
),
Some(p) => {
buttons.push(
button("Set as default")
.icon(Symbol::Accept)
.on_click(write_global.clone())
.into(),
);
buttons.push(
button(format!("Set in \u{201c}{}\u{201d}", p.name))
.accent()
.icon(Symbol::Accept)
.on_click(write_profile(p.id.clone()))
.into(),
);
}
}
buttons.push({
let ss = set_screen.clone();
button("Close")
.icon(Symbol::Cancel)
.on_click(move || ss.call(Screen::Hosts))
.into()
});
let results = card(
vstack((
text_block(format!("{mbps:.0} Mbit/s"))
@@ -190,14 +229,9 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.horizontal_alignment(HorizontalAlignment::Center),
hstack((apply_btn, {
let ss = set_screen.clone();
button("Close")
.icon(Symbol::Cancel)
.on_click(move || ss.call(Screen::Hosts))
}))
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Center),
hstack(buttons)
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Center),
))
.spacing(12.0),
);
+2 -1
View File
@@ -3,7 +3,8 @@
//! Streaming (decode + present) runs in the spawned `punktfunk-session` binary; the shell only
//! needs the list of real (hardware) adapters to offer on a multi-GPU box (a hybrid laptop or an
//! eGPU). The picked adapter description is persisted (`crate::trust::Settings::adapter`) and read
//! by the session child at connect (`PUNKTFUNK_ADAPTER` remains the session binary's env override).
//! by the session child at connect (`PUNKTFUNK_VK_ADAPTER` remains the session binary's env
//! override).
use windows::core::Interface;
use windows::Win32::dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
+77 -25
View File
@@ -55,9 +55,19 @@ impl SessionChild {
/// One parsed stdout line of the session contract; `None` for anything unrecognized.
enum ChildLine {
Ready,
Error { msg: String, trust_rejected: bool },
Error {
msg: String,
trust_rejected: bool,
},
Ended(String),
Stats(String),
/// The session window's logical size settled here under match-window — the SPAWNER
/// persists it (design/client-architecture-split.md §5). This shell ignored the line
/// until 2026-07-31, so its sessions fell back to persisting from the renderer.
Window {
w: u32,
h: u32,
},
}
fn parse_line(line: &str) -> Option<ChildLine> {
@@ -77,6 +87,12 @@ fn parse_line(line: &str) -> Option<ChildLine> {
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
return Some(ChildLine::Ended(msg.to_string()));
}
if let Some(win) = v.get("window") {
let dim = |k: &str| win.get(k).and_then(|n| n.as_u64()).map(|n| n as u32);
if let (Some(w), Some(h)) = (dim("w"), dim("h")) {
return Some(ChildLine::Window { w, h });
}
}
None
}
@@ -107,42 +123,60 @@ pub(crate) fn session_binary() -> std::path::PathBuf {
/// Spawn the session binary for a connect with `fp_hex` pinned and feed its lifecycle to
/// `on_event` from a reader thread. The child is parked in `slot` so Disconnect/Cancel
/// can kill it. `fullscreen` starts the stream window fullscreen (the Settings "Start
/// streams fullscreen" toggle); `launch` carries a library title id for the host to
/// launch during the handshake. `Err` = the spawn itself failed (binary missing?) —
/// surfaced as a connect error by the caller.
/// can kill it. `launch` carries a library title id for the host to launch during the
/// handshake; `profile` is a ONE-OFF settings-profile pick. `Err` = the spawn itself
/// failed (binary missing?) — surfaced as a connect error by the caller.
///
/// The argv and the `--resolved-spec` both come from the shared brain
/// ([`ConnectPlan::for_target`] → `session_args()` + `spec()`): this shell hand-assembled
/// its argv until 2026-07-31, which meant no spec — its sessions took the compat path and
/// re-resolved every setting from the stores (the drift `orchestrate.rs` documents as a
/// trap), and any field added to the spec was silently Windows-dead. Fullscreen now also
/// comes from the plan's EFFECTIVE settings (profile-aware) instead of a caller argument.
#[allow(clippy::too_many_arguments)] // one cohesive spawn spec (session_params precedent)
pub(crate) fn spawn_session(
addr: &str,
port: u16,
fp_hex: &str,
connect_timeout_secs: u64,
fullscreen: bool,
launch: Option<&str>,
profile: Option<&str>,
slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> {
use pf_client_core::orchestrate::{ConnectPlan, HostTarget};
let mut plan = ConnectPlan::for_target(
HostTarget {
name: String::new(), // display-only; this shell's screens carry their own copy
addr: addr.to_string(),
port,
fp_hex: Some(fp_hex.to_string()),
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
id: None,
},
launch.map(str::to_string),
profile.map(str::to_string),
);
plan.connect_timeout_secs = Some(connect_timeout_secs);
let mut cmd = Command::new(session_binary());
cmd.arg("--connect")
.arg(format!("{addr}:{port}"))
.arg("--fp")
.arg(fp_hex)
.arg("--connect-timeout")
.arg(connect_timeout_secs.to_string());
if fullscreen {
cmd.arg("--fullscreen");
}
if let Some(id) = launch {
cmd.arg("--launch").arg(id);
}
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
// binding through the same helper this shell would have used, so the two can't disagree.
if let Some(reference) = profile {
cmd.arg("--profile").arg(reference);
}
let mut args = plan.session_args();
// Spec mode (design/client-architecture-split.md §5): the child reads no stores and
// cannot disagree with us about a file either of us might write. A spec we fail to
// write is not fatal — the compat path resolves the same values via the same helper.
let spec_path = match plan.spec(plan.clipboard).write_temp() {
Ok(path) => {
args.push("--resolved-spec".into());
args.push(path.to_string_lossy().into_owned());
Some(path)
}
Err(e) => {
tracing::warn!(error = %e, "couldn't write the resolved spec; the session will resolve for itself");
None
}
};
cmd.args(args);
add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
spawn_with(cmd, &format!("{addr}:{port}"), spec_path, slot, on_event)
}
/// Spawn the session binary in `--browse` mode: the console (gamepad) library for a
@@ -169,7 +203,7 @@ pub(crate) fn spawn_browse(
}
add_window_pos(&mut cmd);
let label = target.map_or_else(|| "console".to_string(), |(a, p)| format!("{a}:{p}"));
spawn_with(cmd, &label, slot, on_event)
spawn_with(cmd, &label, None, slot, on_event)
}
/// Hand the shell window's position to the child (`--window-pos`) so the session window
@@ -182,9 +216,11 @@ fn add_window_pos(cmd: &mut Command) {
}
/// The shared spawn + stdout-contract reader behind [`spawn_session`]/[`spawn_browse`].
/// `spec_path` is the child's `--resolved-spec` temp file, deleted once the child exits.
fn spawn_with(
mut cmd: Command,
host_label: &str,
spec_path: Option<std::path::PathBuf>,
slot: SessionChild,
mut on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> {
@@ -225,9 +261,19 @@ fn spawn_with(
trust_rejected,
}) => error = Some((msg, trust_rejected)),
Some(ChildLine::Ended(msg)) => ended = Some(msg),
// The window size is the spawner's to persist — the renderer only
// reports it (same handling as orchestrate's own reader).
Some(ChildLine::Window { w, h }) => {
pf_client_core::orchestrate::persist_window_size(w, h);
}
None => {}
}
}
// The spec has done its job the moment the child has read it; a leftover temp
// file in %TEMP% is litter, and one per launch adds up.
if let Some(path) = &spec_path {
let _ = std::fs::remove_file(path);
}
// EOF — reap the child (killed-by-Disconnect lands here too; -1 = no code).
let code = slot
.0
@@ -277,6 +323,12 @@ mod tests {
Some(ChildLine::Stats(s)) => assert!(s.starts_with("1280")),
_ => panic!("stats line"),
}
// The match-window report: the SPAWNER persists it (§5) — dropping this line was
// why Windows sessions fell back to renderer-local persistence.
match parse_line("{\"window\":{\"w\":1600,\"h\":900}}") {
Some(ChildLine::Window { w, h }) => assert_eq!((w, h), (1600, 900)),
_ => panic!("window line"),
}
assert!(parse_line("").is_none());
assert!(parse_line("{\"other\":1}").is_none());
}
+9 -6
View File
@@ -488,12 +488,15 @@ pub(crate) fn note_hdr_capture_failed(source: HdrSource) {
}
#[cfg(target_os = "windows")]
pub fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
// but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just
// direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
// display can't be known here (the virtual display's mode settles after the Welcome); that
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
// IDD-push delivers full-chroma RGB for a 4:4:4 session — BGRA on an SDR display, packed 10-bit
// BT.2020 PQ (`Rgb10a2`) on an HDR one — skipping the subsampling converters entirely. Only a
// backend that ingests RGB and CSCs it to 4:4:4 itself can use that: today just direct-NVENC
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring).
//
// The display's HDR state is deliberately NOT part of this answer, and no longer needs to be:
// both depths have a full-chroma source now, so the chroma resolved here — before the Welcome —
// is the chroma the stream really carries. (It used to be a lie whenever the display was HDR:
// this returned true, the Welcome promised 4:4:4, and the capturer then quietly emitted P010.)
encoder_ingests_rgb_444
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
+137 -3
View File
@@ -44,8 +44,8 @@ use windows::Win32::Graphics::Direct3D11::{
D3D11_USAGE_IMMUTABLE, D3D11_USAGE_STAGING, D3D11_VIEWPORT,
};
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
DXGI_FORMAT, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT,
DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC,
};
/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`.
@@ -362,11 +362,145 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
}
";
/// scRGB FP16 → **R10G10B10A2** (BT.2020 PQ, FULL-range RGB) — one full-res pass, the HDR twin of
/// the SDR 4:4:4 BGRA passthrough. Keeps full chroma all the way to the encoder: NVENC ingests the
/// packed 10-bit RGB (`NV_ENC_BUFFER_FORMAT_ABGR10`) and CSCs it to YUV **4:4:4** itself under
/// FREXT, per the BT.2020/PQ VUI the encoder writes — HEVC Main 4:4:4 10. Without this the HDR
/// path had only [`HdrP010Converter`], whose chroma pass subsamples, so a session that negotiated
/// 4:4:4 on an HDR display silently fell back to 4:2:0.
///
/// The colour math is [`HDR_P010_COMMON`]'s `scrgb_to_pq2020` verbatim — the SAME pixels the P010
/// luma pass starts from — so the two HDR outputs agree bit-for-bit before quantization. Only the
/// destination differs: no RGB→YUV, no studio-range squeeze and no chroma decimation here, just the
/// hardware's UNORM quantization of the PQ values into 10 bits per channel.
///
/// Channel order: DXGI `R10G10B10A2_UNORM` stores R in the low 10 bits, which is exactly what
/// NVENC calls `ABGR10` (it names A2B10G10R10 from the MSB down) — the same relationship the SDR
/// path relies on between DXGI `B8G8R8A8` and NVENC's `ARGB`. So the shader writes natural RGB
/// order and no swizzle is needed.
pub(crate) struct HdrRgb10Converter {
vs: ID3D11VertexShader,
ps: ID3D11PixelShader,
sampler: ID3D11SamplerState,
}
/// R10G10B10A2 pass PS — full-res, writes PQ-encoded BT.2020 RGB straight to the packed 10-bit
/// target. `saturate` is implicit in the UNORM render target; `scrgb_to_pq2020` already clamps.
const HDR_RGB10_PS: &str = r"
#include_common
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
return float4(scrgb_to_pq2020(uv), 1.0);
}
";
impl HdrRgb10Converter {
pub(crate) fn new(device: &ID3D11Device) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader`
// receives `s!()` literals (its contract). Each created COM interface owns its own
// reference, and no raw pointer outlives the call that produced it.
unsafe {
let src = HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON);
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = compile_shader(&src, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
let mut ps = None;
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
// POINT, like the P010 luma pass: this is a 1:1 full-res resample, so every RT pixel
// maps to exactly one source texel centre and filtering would only blur it.
let sd = D3D11_SAMPLER_DESC {
Filter: D3D11_FILTER_MIN_MAG_MIP_POINT,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
ComparisonFunc: D3D11_COMPARISON_NEVER,
MaxLOD: f32::MAX,
..Default::default()
};
let mut sampler = None;
device.CreateSamplerState(&sd, Some(&mut sampler))?;
Ok(Self {
vs: vs.context("rgb10 vs")?,
ps: ps.context("rgb10 ps")?,
sampler: sampler.context("rgb10 sampler")?,
})
}
}
/// A plain (non-planar) RTV of the packed 10-bit output texture. Built once per out-ring slot,
/// like the P010 plane views — never per frame.
pub(crate) fn rtv(
device: &ID3D11Device,
dst: &ID3D11Texture2D,
) -> Result<ID3D11RenderTargetView> {
// SAFETY: one `?`-checked `CreateRenderTargetView` on the live `device` borrow, with a
// fully-initialized descriptor local whose address is taken only for the synchronous call,
// plus a live `Option` out-param.
unsafe {
let desc = D3D11_RENDER_TARGET_VIEW_DESC {
Format: DXGI_FORMAT_R10G10B10A2_UNORM,
ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D,
Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 {
Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 },
},
};
let mut rtv: Option<ID3D11RenderTargetView> = None;
device
.CreateRenderTargetView(
dst,
Some(&desc as *const D3D11_RENDER_TARGET_VIEW_DESC),
Some(&mut rtv),
)
.context("CreateRenderTargetView(R10G10B10A2 out slot)")?;
rtv.context("rgb10 rtv null")
}
}
/// Convert `src_srv` (FP16 scRGB, WxH) into the `R10G10B10A2` texture behind `rtv`.
pub(crate) fn convert(
&self,
ctx: &ID3D11DeviceContext,
src_srv: &ID3D11ShaderResourceView,
rtv: &ID3D11RenderTargetView,
w: u32,
h: u32,
) -> Result<()> {
// SAFETY: all D3D11 work runs on the caller's live `ctx` borrow (the owning capture
// thread's immediate context) over borrowed slices of fully-initialized locals and clones
// of the caller's live SRV/RTV. No raw pointers and no mapping on this path.
unsafe {
ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite
ctx.VSSetShader(&self.vs, None);
ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())]));
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
ctx.IASetInputLayout(None);
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
let vp = D3D11_VIEWPORT {
TopLeftX: 0.0,
TopLeftY: 0.0,
Width: w as f32,
Height: h as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
ctx.RSSetViewports(Some(&[vp]));
ctx.OMSetRenderTargets(Some(&[Some(rtv.clone())]), None);
ctx.PSSetShader(&self.ps, None);
ctx.Draw(3, 0);
// Unbind for the next frame's re-RTV / NVENC read.
ctx.OMSetRenderTargets(Some(&[None]), None);
ctx.PSSetShaderResources(0, Some(&[None]));
Ok(())
}
}
}
/// scRGB FP16 → **P010** (BT.2020 PQ, 10-bit limited/studio range) conversion, in OUR OWN shader (two
/// passes: full-res luma + half-res chroma). NVIDIA's D3D11 VideoProcessor cannot do RGB→P010 (renders
/// green), so we quantize to studio-range 10-bit YUV directly and feed NVENC native P010 — skipping
/// NVENC's internal RGB→YUV CSC (which runs on the contended SM). One per capture device (rebuilt on
/// device recreate).
/// device recreate). The 4:4:4 twin is [`HdrRgb10Converter`].
///
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
+49 -29
View File
@@ -20,8 +20,8 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter,
WinCaptureTarget,
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
VideoConverter, WinCaptureTarget,
};
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{bail, Context, Result};
@@ -44,8 +44,8 @@ use windows::Win32::Graphics::Direct3D11::{
};
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM,
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM,
DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
@@ -178,6 +178,9 @@ struct OutSlot {
/// `(luma R16_UNORM, chroma R16G16_UNORM)` plane views. `None` for NV12/BGRA outputs, which the
/// video processor or a plain `CopyResource` writes without an RTV of ours.
p010: Option<(ID3D11RenderTargetView, ID3D11RenderTargetView)>,
/// Plain RTV of the packed 10-bit slot, for the HDR + 4:4:4 output ([`HdrRgb10Converter`]).
/// `None` for every other format.
rgb10: Option<ID3D11RenderTargetView>,
}
/// One PyroWave output-ring slot: the two SEPARATE shareable plane textures the wavelet encoder
@@ -438,8 +441,9 @@ pub struct IddPushCapturer {
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
/// TRUE 4:4:4 and the conversion follows the VUI matrix (BT.709 limited, always written).
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
/// While the display is HDR the same idea runs at 10 bits: [`HdrRgb10Converter`] writes packed
/// BT.2020 PQ RGB and NVENC CSCs that to YUV 4:4:4 (Main 4:4:4 10). Either way the chroma the
/// Welcome promised is the chroma the wire carries.
want_444: bool,
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md +
/// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring`
@@ -521,10 +525,15 @@ pub struct IddPushCapturer {
/// SDR — keeps the colour-convert OFF the contended 3D/compute engine. Built lazily; rebuilt on a
/// size/HDR flip.
video_conv: Option<VideoConverter>,
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is HDR
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is
/// HDR and the session did NOT negotiate 4:4:4 (that case takes [`Self::hdr_rgb10_conv`])
/// (NVIDIA's VideoProcessor can't do RGB→P010). The passes run on the 3D engine, but it still skips
/// NVENC's internal SM-side CSC. Built lazily.
hdr_p010_conv: Option<HdrP010Converter>,
/// FP16 scRGB slot → packed 10-bit BT.2020 PQ RGB, used while the display is HDR **and** the
/// session negotiated 4:4:4 — the full-chroma twin of [`Self::hdr_p010_conv`]. Rebuilt with the
/// ring on a mode/HDR flip.
hdr_rgb10_conv: Option<HdrRgb10Converter>,
last_seq: u64,
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
status_logged: bool,
@@ -649,8 +658,10 @@ impl IddPushCapturer {
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
/// auto-switch, exactly as on the WGC path. HDR and 4:4:4 now COMPOSE: an HDR display that
/// negotiated full chroma emits packed 10-bit BT.2020 PQ RGB (`Rgb10a2`) for NVENC to CSC to
/// YUV 4:4:4 — HEVC Main 4:4:4 10. (Before, HDR won and the stream silently downgraded to
/// 4:2:0 *after* the Welcome had already promised 4:4:4.)
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
// PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the
// format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR
@@ -664,7 +675,10 @@ impl IddPushCapturer {
}
if self.display_hdr {
if self.want_444 {
warn_444_hdr_downgrade_once();
// HDR + full chroma: packed 10-bit RGB (BT.2020 PQ), which NVENC CSCs to YUV
// 4:4:4 itself — the HDR twin of the SDR BGRA passthrough below. No subsampling
// anywhere on this path (see `HdrRgb10Converter`).
return (DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2);
}
(DXGI_FORMAT_P010, PixelFormat::P010)
} else if self.want_444 {
@@ -798,6 +812,7 @@ impl IddPushCapturer {
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None;
self.hdr_rgb10_conv = None;
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
@@ -981,7 +996,12 @@ impl IddPushCapturer {
} else {
None
};
self.out_ring.push(OutSlot { tex, p010 });
let rgb10 = if format == DXGI_FORMAT_R10G10B10A2_UNORM {
Some(HdrRgb10Converter::rtv(&self.device, &tex)?)
} else {
None
};
self.out_ring.push(OutSlot { tex, p010, rgb10 });
}
}
Ok(())
@@ -1076,7 +1096,13 @@ impl IddPushCapturer {
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
fn ensure_converter(&mut self) -> Result<()> {
if self.display_hdr {
if self.display_hdr && self.want_444 {
// HDR + full chroma: one full-res pass to packed 10-bit BT.2020 PQ RGB; NVENC does
// the RGB→YUV444 CSC (there is nothing to subsample, so no second pass).
if self.hdr_rgb10_conv.is_none() {
self.hdr_rgb10_conv = Some(HdrRgb10Converter::new(&self.device)?);
}
} else if self.display_hdr {
if self.hdr_p010_conv.is_none() {
self.hdr_p010_conv = Some(HdrP010Converter::new(
&self.device,
@@ -1537,7 +1563,7 @@ impl IddPushCapturer {
self.ensure_out_ring()?;
self.ensure_converter()?;
let s = &self.out_ring[i];
(Some((s.tex.clone(), s.p010.clone())), None)
(Some((s.tex.clone(), s.p010.clone(), s.rgb10.clone())), None)
};
let (_, pf) = self.out_format();
let ring_len = if self.pyrowave {
@@ -1584,11 +1610,20 @@ impl IddPushCapturer {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
conv.convert(&self.context, src, y_rtv, cbcr_rtv, self.width, self.height)?;
}
} else if self.display_hdr && self.want_444 {
// HDR 4:4:4: FP16 slot SRV → packed 10-bit BT.2020 PQ RGB; NVENC ingests it as
// ABGR10 and CSCs to YUV 4:4:4 under FREXT (HEVC Main 4:4:4 10).
if let Some(conv) = self.hdr_rgb10_conv.as_ref() {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
let (_, _, rtv) = out.as_ref().expect("out ring");
let rtv = rtv.as_ref().expect("Rgb10a2 out slot has an RTV");
conv.convert(&self.context, src, rtv, self.width, self.height)?;
}
} else if self.display_hdr {
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
if let Some(conv) = self.hdr_p010_conv.as_ref() {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
let (_, rtvs) = out.as_ref().expect("out ring");
let (_, rtvs, _) = out.as_ref().expect("out ring");
// The slot's P010 plane views, built once in `ensure_out_ring`.
let (y_rtv, uv_rtv) = rtvs.as_ref().expect("P010 out slot has plane RTVs");
conv.convert(&self.context, src, y_rtv, uv_rtv, self.width, self.height)?;
@@ -2008,21 +2043,6 @@ impl Capturer for IddPushCapturer {
}
}
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
/// desktop needs the PQ tone curve, which the P010 shader provides at 4:2:0), so the stream
/// honestly downgrades — the encoder's `chroma_444` caps cross-check reports it and the in-band
/// SPS keeps the client decoding correctly. Once per process: the state can flap mid-session.
fn warn_444_hdr_downgrade_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::warn!(
"4:4:4 negotiated but the display is HDR — no 10-bit full-chroma source exists; \
encoding HDR 4:2:0 (P010) instead (disable HDR on the virtual display for 4:4:4)"
);
}
}
impl Drop for IddPushCapturer {
fn drop(&mut self) {
// A channel session ending while the secure-desktop guard is engaged must not leave the
@@ -644,6 +644,7 @@ impl IddPushCapturer {
out_idx: 0,
video_conv: None,
hdr_p010_conv: None,
hdr_rgb10_conv: None,
last_seq: 0,
last_present: None,
status_logged: false,
+2
View File
@@ -68,6 +68,8 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
"d3dcommon",
"dxgi",
"handleapi",
# RECT/HMONITOR for DXGI_OUTPUT_DESC1 (the display-HDR volume query).
"windef",
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES.
"minwinbase",
# The GlobalAlloc block the clipboard takes ownership of (clipboard.rs).
+93 -9
View File
@@ -32,6 +32,91 @@ const CHANNELS: usize = 2;
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
const MIC_FRAME: usize = 960;
/// A selectable WASAPI endpoint for the settings pickers.
#[derive(Clone, Debug)]
pub struct AudioDevice {
/// The `IMMDevice` endpoint id (`{0.0.0.00000000}.{…}`) — the stable key the render and
/// capture threads resolve via [`DeviceEnumerator::get_device`]. (The PipeWire twin
/// stores `node.name` here; both are "the stable key", so the Settings fields and env
/// contract stay OS-agnostic.)
pub name: String,
/// The endpoint's friendly name ("Speakers (Realtek …)") — what the picker shows.
pub description: String,
}
/// Enumerate active audio endpoints: `(sinks, sources)` — the WASAPI twin of the PipeWire
/// probe (same tuple shape; no devices → the caller simply shows no pickers). Runs on its
/// own short-lived MTA thread: the caller is typically a UI thread whose COM apartment is
/// STA, where a direct `CoInitializeEx(MTA)` would fail with `RPC_E_CHANGED_MODE`.
pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
std::thread::Builder::new()
.name("pf-audio-enum".into())
.spawn(|| -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
wasapi::initialize_mta()
.ok()
.context("CoInitializeEx (MTA)")?;
let enumerator = DeviceEnumerator::new().context("DeviceEnumerator")?;
let mut out = (Vec::new(), Vec::new());
for (direction, list) in [
(Direction::Render, &mut out.0),
(Direction::Capture, &mut out.1),
] {
let coll = enumerator
.get_device_collection(&direction)
.context("device collection")?;
for i in 0..coll.get_nbr_devices().context("device count")? {
// One broken endpoint (driver limbo) must not hide the rest.
let Ok(dev) = coll.get_device_at_index(i) else {
continue;
};
let (Ok(id), Ok(name)) = (dev.get_id(), dev.get_friendlyname()) else {
continue;
};
list.push(AudioDevice {
name: id,
description: name,
});
}
}
Ok(out)
})
.context("spawn audio enumeration thread")?
.join()
.map_err(|_| anyhow!("audio enumeration thread panicked"))?
}
/// The endpoint an env pick names (`PUNKTFUNK_AUDIO_SINK`/`SOURCE` — endpoint ids, the
/// Settings device pickers via session main), or the OS default. A picked device that's
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
fn pick_device(
enumerator: &DeviceEnumerator,
direction: &Direction,
var: &str,
) -> Result<wasapi::Device> {
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
match enumerator.get_device(&id) {
Ok(d) => {
tracing::info!(
var,
endpoint = %d.get_friendlyname().unwrap_or_else(|_| id.clone()),
"using the picked audio endpoint"
);
return Ok(d);
}
Err(e) => tracing::warn!(
var,
endpoint_id = %id,
error = %e,
"picked audio endpoint not found — using the default"
),
}
}
enumerator
.get_default_device(direction)
.context("default endpoint")
}
pub struct AudioPlayer {
pcm_tx: SyncSender<Vec<f32>>,
/// Drained chunk Vecs coming back from the render thread for reuse (the pool half of
@@ -66,7 +151,8 @@ impl AudioPlayer {
.context("spawn audio thread")?;
match ready_rx.recv_timeout(Duration::from_secs(3)) {
Ok(Ok(())) => {
tracing::info!(channels, "WASAPI render: 48 kHz f32 (default endpoint)");
// Default endpoint unless PUNKTFUNK_AUDIO_SINK picked one (logged there).
tracing::info!(channels, "WASAPI render: 48 kHz f32");
Ok(AudioPlayer {
pcm_tx,
recycle_rx,
@@ -124,10 +210,9 @@ fn render_thread(
// F32LE interleaved: channels × 4 bytes/sample. Stereo (channels == 2) is byte-identical
// to the old fixed path (mask 0x3, block align 8).
let block_align = channels as usize * 4;
let device = DeviceEnumerator::new()
.context("DeviceEnumerator")?
.get_default_device(&Direction::Render)
.context("default render endpoint")?;
let enumerator = DeviceEnumerator::new().context("DeviceEnumerator")?;
let device = pick_device(&enumerator, &Direction::Render, "PUNKTFUNK_AUDIO_SINK")
.context("render endpoint")?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// The explicit dwChannelMask is the wire order (FL FR FC LFE RL RR SL SR); 5.1 = 0x3F,
// 7.1 = 0x63F. WASAPI delivers channels in ascending mask-bit order, which equals the wire
@@ -265,10 +350,9 @@ fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> Result<()
.map_err(|e| anyhow!("opus encoder: {e}"))?;
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
let device = DeviceEnumerator::new()
.context("DeviceEnumerator")?
.get_default_device(&Direction::Capture)
.context("default capture endpoint (no microphone?)")?;
let enumerator = DeviceEnumerator::new().context("DeviceEnumerator")?;
let device = pick_device(&enumerator, &Direction::Capture, "PUNKTFUNK_AUDIO_SOURCE")
.context("capture endpoint (no microphone?)")?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
let desired = WaveFormat::new(32, 32, &SampleType::Float, SAMPLE_RATE, CHANNELS, None);
let (default_period, _min_period) =
+1 -5
View File
@@ -366,11 +366,7 @@ pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
.then(|| parse_addr_port(&link.host_ref))
.flatten();
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
if let Some(i) = known
.hosts
.iter()
.position(|h| h.addr == candidate.0 && h.port == candidate.1)
{
if let Some(i) = known.index_by_addr(&candidate.0, candidate.1) {
return HostResolution::Known(i);
}
}
+7 -3
View File
@@ -132,9 +132,7 @@ impl ConnectPlan {
// exactly the right thing: no profile binding, no clipboard opt-in.
let fallback = KnownHost::default();
let stored = known
.hosts
.iter()
.find(|h| h.addr == host.addr && h.port == host.port)
.find_by_addr(&host.addr, host.port)
.unwrap_or(&fallback);
let mut plan = ConnectPlan::resolve(
stored,
@@ -230,6 +228,12 @@ impl ConnectPlan {
if self.settings.fullscreen_on_stream {
args.push("--fullscreen".into());
}
// Deliberately NO `--window-pos` here. The Windows shell appends its own (its
// window's desktop coordinates place the session on the same monitor), but on
// Wayland neither GTK can read global window coordinates nor can SDL apply
// them — the compositor owns placement — so from the GTK/CLI spawners the flag
// would be a silent no-op everywhere it matters. X11 could carry it, but a
// Linux-only special case that most Linux sessions ignore isn't worth the drift.
args
}
}
+115 -2
View File
@@ -78,6 +78,30 @@ pub struct SessionParams {
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
/// re-reading any store (design/client-settings-profiles.md §5.2).
pub profile: Option<String>,
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
/// (design/phase-locked-capture.md — previously Apple/Android only). Never set without
/// present timing: the host arms on report receipt, but the Hello should say what the
/// client actually does.
pub phase_lock: bool,
/// The presenter-written latch grid the pump's reports are computed from.
pub latch_grid: Arc<LatchGrid>,
}
/// The presenter's display-latch grid, shared presenter → pump (the `force_software`
/// pattern in the other direction): the presenter's 1 Hz present-timing fold writes a
/// recent on-glass latch instant plus the panel period; the pump's stats window folds its
/// per-AU arrival stamps against them into the ~1 Hz `PhaseReport`. All zeros until the
/// first fold — and forever when present timing isn't available — so the pump simply
/// stays quiet then.
#[derive(Default)]
pub struct LatchGrid {
/// A recent on-glass latch instant (client `CLOCK_REALTIME` ns — the same domain as
/// the AU arrival stamps). Any grid point works; the report extrapolates forward.
pub anchor_ns: std::sync::atomic::AtomicU64,
/// The panel's latch period (ns). `0` = no grid yet.
pub period_ns: std::sync::atomic::AtomicU64,
}
/// The session pump's share of the unified stats window (design/stats-unification.md):
@@ -124,6 +148,21 @@ pub struct Stats {
/// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty
/// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback.
pub decoder: &'static str,
/// The encoder's CURRENT target bitrate (kbps): the Welcome resolve, then live per
/// `BitrateChanged` ack. What `mbps` (measured goodput) is judged AGAINST — a user
/// staring at "19 Mb/s" can't otherwise tell "the encoder is capped at 20" from "my
/// 200 Mb/s ask was honoured and this scene is cheap" (the gap that let the
/// settings-drop bug ship four releases). `0` = an old host that never reported one.
pub target_kbps: u32,
/// Automatic bitrate is armed (ABR moves `target_kbps` on its own) — the OSD tags the
/// target `(auto)` so a moving figure reads as policy, not a broken setting.
pub auto_rate: bool,
/// The host resolved full-chroma 4:4:4 for this session (`Welcome::chroma_format`).
pub chroma_444: bool,
/// This session ADVERTISED `VIDEO_CAP_444` (the Settings "Full chroma" opt-in): with
/// `chroma_444` false, the host declined — the OSD says so instead of leaving the
/// switch's effect unobservable.
pub asked_444: bool,
}
/// Frames the pump keeps waiting for their 0xCF host timing (pts → capture→received µs).
@@ -267,11 +306,17 @@ fn pump(
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
if params.cursor_forward {
// CURSOR: this embedder renders the host cursor locally in desktop mouse mode.
// PHASE_LOCK: the presenter has real latch stamps and the pump reports them below.
(if params.cursor_forward {
punktfunk_core::quic::CLIENT_CAP_CURSOR
} else {
0
},
}) | (if params.phase_lock {
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
} else {
0
}),
// Slice-progressive delivery: off — this presenter feeds FFmpeg whole AUs; a partial
// avcodec feed path can flip it later.
false,
@@ -361,6 +406,12 @@ fn pump(
}
};
let force_software = params.force_software.clone();
// Session-constant stats facts (design/stats-unification.md): what the target figure is
// judged against and whether the 4:4:4 opt-in was honoured. `target_kbps` itself is read
// live per window — an Automatic session's ABR moves it.
let auto_rate = connector.wants_decode_latency();
let chroma_444 = connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444;
let asked_444 = params.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
// Audio is best-effort: a session without it still streams. Gamepads are the
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client.
@@ -391,6 +442,13 @@ fn pump(
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
let clock_offset_live = connector.clock_offset_shared();
// Phase-lock (advertised above): every received AU's arrival stamp, folded per stats
// window against the presenter's latch grid into the ~1 Hz PhaseReport. Desktop
// sessions receive whole AUs only (no frame parts), so every arrival counts — the
// reference reporters (Apple/Android) sample the same signal. 256 ≈ 2 s at 120 Hz.
let latch_grid = params.latch_grid.clone();
let mut phase_arrivals: Vec<u64> = Vec::new();
let mut last_applied_phase: Option<i32> = None;
// PUNKTFUNK_DEBUG_RECONFIGURE=WxH@HZ:SECS — lab lever: request ONE mid-stream mode
// switch N seconds in, so a headless session (no window manager to drag a window in)
// can exercise the resize path deterministically — host pipeline rebuild, decoder
@@ -480,6 +538,9 @@ fn pump(
} else {
now_ns()
};
if params.phase_lock && phase_arrivals.len() < 256 {
phase_arrivals.push(received_ns);
}
// fps / goodput count every received AU (spec), decoded or not.
frames_n += 1;
bytes_n += frame.data.len() as u64;
@@ -733,6 +794,19 @@ fn pump(
// host never emits any — the deque fills to its cap and the OSD keeps the
// combined `host+network` stage.
while let Ok(t) = connector.next_host_timing(Duration::ZERO) {
// Phase-lock closed loop: the host's applied grid offset rides the 0xCF tail.
// Log transitions so an on-glass run can watch the controller engage/settle
// (the Android reporter's parity log).
if params.phase_lock
&& t.applied_phase_ns.is_some()
&& t.applied_phase_ns != last_applied_phase
{
last_applied_phase = t.applied_phase_ns;
tracing::info!(
applied_phase_ns = t.applied_phase_ns.unwrap_or(0),
"host phase-lock: applied capture-grid offset"
);
}
if let Some(i) = pending_split.iter().position(|(p, _)| *p == t.pts_ns) {
let (_, hn_us) = pending_split.remove(i).unwrap();
host_us_win.push(t.host_us as u64);
@@ -775,6 +849,41 @@ fn pump(
}
if window_start.elapsed() >= Duration::from_secs(1) {
// Phase-lock report (~1 Hz, riding the stats window — the reference reporters'
// cadence): this window's arrival leads before the presenter's latch grid,
// folded with the SHARED circular statistic (the host controller was tuned
// against it). Quiet until the presenter has a grid (period 0 — no
// present-timing samples yet) or the window is thin (< 8 arrivals —
// `circular_latch` declines). 1 ms uncertainty = Apple/Android parity.
if params.phase_lock {
let period = latch_grid.period_ns.load(Ordering::Relaxed);
let anchor = latch_grid.anchor_ns.load(Ordering::Relaxed);
if period > 0 && anchor > 0 {
let leads_us: Vec<u64> = phase_arrivals
.iter()
.map(|a| {
((anchor as i128 - *a as i128).rem_euclid(period as i128) / 1000) as u64
})
.collect();
if let Some((lead_ns, coherence)) =
punktfunk_core::phase::circular_latch(&leads_us, period as i64)
{
// Extrapolate the (possibly ~1 s old) anchor to the next latch at
// or after now, then express it on the host clock.
let (now, p, a) = (now_ns() as i128, period as i128, anchor as i128);
let k = ((now - a).max(0) + p - 1) / p;
let offset = clock_offset_live.load(Ordering::Relaxed) as i128;
connector.report_phase(
(a + k * p + offset).max(0) as u64,
period.min(u32::MAX as u64) as u32,
1_000_000,
lead_ns.min(u32::MAX as u64) as u32,
coherence,
);
}
}
phase_arrivals.clear();
}
let secs = window_start.elapsed().as_secs_f32();
let (hn_p50, _) = window_percentiles(&mut hostnet_us);
let (dec_p50, _) = window_percentiles(&mut decode_us);
@@ -823,6 +932,10 @@ fn pump(
0.0
},
decoder: dec_path,
target_kbps: connector.current_bitrate_kbps(),
auto_rate,
chroma_444,
asked_444,
}));
window_start = Instant::now();
frames_n = 0;
+370 -19
View File
@@ -268,8 +268,40 @@ impl KnownHosts {
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
}
/// The record an address-keyed lookup resolves to, by index (so callers that go on to
/// mutate the store don't fight the borrow checker).
///
/// One address cannot host two live identities at once, but the store can still hold more
/// than one record claiming `addr:port`: an fp-less placeholder waiting for its first
/// ceremony, or — before [`KnownHosts::upsert_trusted`] existed — a re-keyed host whose new
/// record was appended beside the dead one. Resolving that positionally is what turned a
/// host reinstall into a permanent lockout: the dead pin, written first, won every later
/// connect, including right after a successful re-pair.
///
/// So the rule is "the newest trust decision wins": a real fingerprint beats a placeholder,
/// and among real ones the LAST record — records are only ever appended by an explicit
/// trust decision, so the last one is the most recent thing the user actually authorised.
/// That is a lookup order, never an authorisation: whichever record this picks, the pin it
/// yields still has to match the certificate the host presents, or the connect fails closed.
pub fn index_by_addr(&self, addr: &str, port: u16) -> Option<usize> {
let mut best: Option<usize> = None;
for (i, h) in self.hosts.iter().enumerate() {
if h.addr != addr || h.port != port {
continue;
}
let better = match best {
None => true,
Some(b) => !h.fp_hex.is_empty() || self.hosts[b].fp_hex.is_empty(),
};
if better {
best = Some(i);
}
}
best
}
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
self.index_by_addr(addr, port).map(|i| &self.hosts[i])
}
/// Forget the entry with this fingerprint. Returns true if one was removed (the user
@@ -322,6 +354,70 @@ impl KnownHosts {
self.hosts.push(entry);
}
}
/// [`upsert`](Self::upsert) for an **authorised trust decision** — a PIN ceremony, a
/// delegated approval, a TOFU accept, a headless pair — which additionally retires every
/// other record claiming the same `addr:port`.
///
/// `upsert` alone keys on the fingerprint, deliberately: that is how a host which moved
/// address keeps its record and the fields the user set on it. The cost was that a host
/// which changed IDENTITY — a reinstall, a wiped `ProgramData`, a re-key — matched nothing
/// and got a SECOND record appended for the address it already had, and every later
/// connect then pinned the dead fingerprint from the older one. No way out from the UI,
/// and re-pairing didn't help: the ceremony succeeded and appended yet another record.
///
/// A record retired here carries what describes the BOX rather than the identity onto the
/// record that survives — its MAC, its OS chain, the profile bound to it, its pinned cards,
/// when it was last used — so a reinstall doesn't quietly cost the user their setup.
/// Deliberately NOT carried: `paired` and `clipboard_sync`, which are decisions about one
/// specific certificate and have to be made again for a new one, and the stable record id
/// (a deep link written from the retired record falls through to the `host=` recovery the
/// link grammar already specifies, rather than silently pointing at a new identity).
///
/// **Only trust decisions may call this.** Everything that merely LEARNS something about a
/// host — a rediscovery, the wake path's address re-key — stays on plain `upsert`: those
/// are driven by unauthenticated mDNS, and letting an advert delete a saved host by
/// claiming its address would trade this bug for a much worse one.
pub fn upsert_trusted(&mut self, entry: KnownHost) {
let (addr, port, fp_hex) = (entry.addr.clone(), entry.port, entry.fp_hex.clone());
self.upsert(entry);
// Nothing to supersede *with*: an fp-less record is a placeholder, not an identity.
if fp_hex.is_empty() {
return;
}
let (keep, retired): (Vec<KnownHost>, Vec<KnownHost>) = std::mem::take(&mut self.hosts)
.into_iter()
.partition(|h| !(h.addr == addr && h.port == port && h.fp_hex != fp_hex));
self.hosts = keep;
if retired.is_empty() {
return;
}
let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) else {
return;
};
for old in retired {
tracing::info!(
addr = %addr, port,
retired_fp = %old.fp_hex, kept_fp = %fp_hex,
"host re-keyed — retiring the superseded record for this address"
);
if h.mac.is_empty() {
h.mac = old.mac;
}
if h.os.is_empty() {
h.os = old.os;
}
if h.profile_id.is_none() {
h.profile_id = old.profile_id;
}
if h.pinned_profiles.is_empty() {
h.pinned_profiles = old.pinned_profiles;
}
if h.last_used.is_none() {
h.last_used = old.last_used;
}
}
}
}
/// Load-upsert-save in one step — the pin every trust decision (TOFU accept, PIN
@@ -332,7 +428,10 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
// so every user-set field (clipboard, profile binding, pins) must arrive as "not carried"
// — `upsert` then leaves an existing host's own settings alone. A hand-written literal
// here is how those fields would get silently reset on the next re-pair.
known.upsert(KnownHost {
//
// `upsert_trusted`, not `upsert`: this IS the authorised decision, so it is also the point
// at which a host that re-keyed retires its own dead record for this address.
known.upsert_trusted(KnownHost {
name: name.to_string(),
addr: addr.to_string(),
port,
@@ -366,6 +465,23 @@ pub fn forget_placeholder(addr: &str, port: u16) {
}
}
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
/// data the live host advertised, purely because it came earlier in the file.
fn learn_target<'a>(
known: &'a mut KnownHosts,
fp_hex: &str,
addr: &str,
port: u16,
) -> Option<&'a mut KnownHost> {
let i = (!fp_hex.is_empty())
.then(|| known.hosts.iter().position(|h| h.fp_hex == fp_hex))
.flatten()
.or_else(|| known.index_by_addr(addr, port))?;
known.hosts.get_mut(i)
}
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
@@ -374,11 +490,7 @@ pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
return;
}
let mut known = KnownHosts::load();
let Some(h) = known
.hosts
.iter_mut()
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
else {
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mac == mac {
@@ -396,11 +508,7 @@ pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
return;
}
let mut known = KnownHosts::load();
let Some(h) = known
.hosts
.iter_mut()
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
else {
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.os == os {
@@ -785,9 +893,10 @@ pub struct Settings {
#[serde(default)]
pub invert_scroll: bool,
/// Playback endpoint for stream audio — on Linux the PipeWire `node.name` the
/// playback stream targets (`target.object`); empty = the session default (the
/// Apple client's Speaker picker). The session maps it onto `PUNKTFUNK_AUDIO_SINK`.
/// Ignored on Windows until the WASAPI endpoint leg exists.
/// playback stream targets (`target.object`); on Windows the WASAPI `IMMDevice`
/// endpoint id; empty = the OS default (the Apple client's Speaker picker). The
/// session maps it onto `PUNKTFUNK_AUDIO_SINK`. A picked endpoint that's gone
/// falls back to the default on both OSes.
#[serde(default)]
pub speaker_device: String,
/// Capture endpoint for the mic uplink (same semantics as `speaker_device`;
@@ -960,10 +1069,9 @@ pub fn effective_settings(
) -> (Settings, Option<StreamProfile>) {
let base = Settings::load();
let catalog = ProfilesFile::load();
let bound = KnownHosts::load()
.hosts
.iter()
.find(|h| h.addr == addr && h.port == port)
let known = KnownHosts::load();
let bound = known
.find_by_addr(addr, port)
.and_then(|h| h.profile_id.clone());
match resolve_profile(&catalog, bound.as_deref(), one_off) {
@@ -1003,6 +1111,12 @@ fn resolve_profile(
mod tests {
use super::*;
/// A 64-hex fingerprint of one repeated digit — readable in an assertion, and distinct
/// per letter, which is all the known-hosts tests need one to be.
fn fp(c: char) -> String {
std::iter::repeat_n(c, 64).collect()
}
/// A settings file predating the touch-input model loads as `trackpad` (the shipped
/// default), and the name round-trips through the enum both ways.
#[test]
@@ -1220,6 +1334,243 @@ mod tests {
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
}
/// A host that regenerated its identity (reinstall, wiped ProgramData, re-key) ends up with
/// ONE record for its address — the live one. This is the `.173` lockout: `upsert` keys on
/// the fingerprint, so the re-paired host used to be appended beside the dead record, and
/// every later connect pinned the dead one — forever, re-pairing included.
#[test]
fn upsert_trusted_supersedes_a_rekeyed_host() {
let (dead, live) = (fp('c'), fp('a'));
let mut k = KnownHosts {
hosts: vec![KnownHost {
name: "ENRICOS-DESKTOP (local)".into(),
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: dead.clone(),
paired: true,
last_used: Some(1000),
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
os: "windows".into(),
clipboard_sync: true,
profile_id: Some("aaaaaaaaaaaa".into()),
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
id: Some("11111111-2222-4333-8444-555555555555".into()),
}],
};
// The re-pair: same box, same address, a certificate the client has never seen.
k.upsert_trusted(KnownHost {
name: "127.0.0.1".into(),
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: live.clone(),
paired: true,
..Default::default()
});
assert_eq!(k.hosts.len(), 1);
let h = &k.hosts[0];
assert_eq!(h.fp_hex, live);
// …and the address now resolves to the live pin, which is the whole bug.
assert_eq!(k.find_by_addr("127.0.0.1", 9777).unwrap().fp_hex, live);
assert!(k.find_by_fp(&dead).is_none());
// What describes the BOX rides along, so a reinstall doesn't cost the user their setup.
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
assert_eq!(h.os, "windows");
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
assert_eq!(h.last_used, Some(1000));
// What described the dead IDENTITY does not: the clipboard grant is a decision about
// one certificate, and the retired record's stable id must not follow a new one.
assert!(!h.clipboard_sync);
assert_ne!(
h.id.as_deref(),
Some("11111111-2222-4333-8444-555555555555")
);
}
/// The case fingerprint-keying exists for still works through the trusted path: a host that
/// only MOVED keeps its one record, its `paired` bit and everything the user set on it —
/// including the clipboard grant and the stable id, which a same-identity re-pair must not
/// disturb (that would be the fix trading one silent reset for another).
#[test]
fn upsert_trusted_keeps_a_host_that_only_moved_address() {
let same = fp('a');
let mut k = KnownHosts {
hosts: vec![KnownHost {
name: "Desk".into(),
addr: "192.168.1.50".into(),
port: 9777,
fp_hex: same.clone(),
paired: true,
clipboard_sync: true,
profile_id: Some("aaaaaaaaaaaa".into()),
id: Some("11111111-2222-4333-8444-555555555555".into()),
..Default::default()
}],
};
k.upsert_trusted(KnownHost {
name: "Desk".into(),
addr: "192.168.1.51".into(),
port: 9777,
fp_hex: same.clone(),
paired: false, // must not demote
..Default::default()
});
assert_eq!(k.hosts.len(), 1);
let h = &k.hosts[0];
assert_eq!(h.addr, "192.168.1.51");
assert!(h.paired);
assert!(h.clipboard_sync);
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
assert_eq!(
h.id.as_deref(),
Some("11111111-2222-4333-8444-555555555555")
);
}
/// Superseding is scoped to the address the decision was made for, and only ever runs off
/// one: a trust decision for `.51` leaves a different host saved at `.50` alone, and an
/// fp-less save (a manual entry, `--add-host` without `--fp`) retires nothing at all — it
/// carries no identity to supersede anything WITH.
#[test]
fn upsert_trusted_leaves_other_addresses_and_placeholders_alone() {
let mut k = KnownHosts {
hosts: vec![
KnownHost {
name: "Other box".into(),
addr: "192.168.1.50".into(),
port: 9777,
fp_hex: fp('c'),
paired: true,
..Default::default()
},
// Same address, DIFFERENT port: a distinct endpoint, not a duplicate.
KnownHost {
name: "Second host".into(),
addr: "192.168.1.51".into(),
port: 9778,
fp_hex: fp('d'),
paired: true,
..Default::default()
},
],
};
k.upsert_trusted(KnownHost {
name: "New box".into(),
addr: "192.168.1.51".into(),
port: 9777,
fp_hex: fp('a'),
paired: true,
..Default::default()
});
assert_eq!(k.hosts.len(), 3);
assert_eq!(
k.find_by_addr("192.168.1.50", 9777).unwrap().fp_hex,
fp('c')
);
assert_eq!(
k.find_by_addr("192.168.1.51", 9778).unwrap().fp_hex,
fp('d')
);
// An fp-less save alongside a real record: nothing is retired, and the address still
// resolves to the record that HAS a pin.
k.upsert_trusted(KnownHost {
name: "Typed by hand".into(),
addr: "192.168.1.50".into(),
port: 9777,
..Default::default()
});
assert_eq!(k.hosts.len(), 4);
assert_eq!(
k.find_by_addr("192.168.1.50", 9777).unwrap().fp_hex,
fp('c')
);
}
/// A store that ALREADY holds the duplicate (every client shipped so far can have written
/// one) connects again on the next connect, before any re-pair: an address resolves to the
/// newest trust decision for it, not to whichever record happens to sit first in the file.
/// Nothing is deleted at load — which record is live isn't knowable there, and guessing
/// wrong would throw away the good one; the retirement waits for the next trust decision.
#[test]
fn a_duplicated_store_resolves_to_the_newest_record() {
let (dead, live) = (fp('c'), fp('a'));
let mut k = KnownHosts {
hosts: vec![
KnownHost {
name: "ENRICOS-DESKTOP (local)".into(),
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: dead.clone(),
paired: true,
last_used: Some(9999), // the stale record is the one that HAS connected
..Default::default()
},
KnownHost {
name: "127.0.0.1".into(),
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: live.clone(),
paired: true,
..Default::default()
},
],
};
assert_eq!(k.find_by_addr("127.0.0.1", 9777).unwrap().fp_hex, live);
// Loading is non-destructive: both records are still there to be looked up by pin.
assert!(k.find_by_fp(&dead).is_some());
// A placeholder appended later never displaces a real pin.
k.hosts.push(KnownHost {
addr: "127.0.0.1".into(),
port: 9777,
..Default::default()
});
assert_eq!(k.find_by_addr("127.0.0.1", 9777).unwrap().fp_hex, live);
// …and the next trust decision cleans the store up.
k.upsert_trusted(KnownHost {
name: "127.0.0.1".into(),
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: live.clone(),
paired: true,
..Default::default()
});
assert_eq!(k.hosts.len(), 1);
assert_eq!(k.hosts[0].fp_hex, live);
}
/// An advert's learned MAC/OS lands on the record it identified, not on a stale namesake
/// at the same address that merely came first in the file.
#[test]
fn learn_target_prefers_the_fingerprint_match() {
let (dead, live) = (fp('c'), fp('a'));
let mut k = KnownHosts {
hosts: vec![
KnownHost {
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: dead.clone(),
..Default::default()
},
KnownHost {
addr: "127.0.0.1".into(),
port: 9777,
fp_hex: live.clone(),
..Default::default()
},
],
};
learn_target(&mut k, &live, "127.0.0.1", 9777).unwrap().os = "windows".into();
assert_eq!(k.find_by_fp(&live).unwrap().os, "windows");
assert_eq!(k.find_by_fp(&dead).unwrap().os, "");
// No fingerprint to go on (an advert that carries none) → the address's own answer.
learn_target(&mut k, "", "127.0.0.1", 9777).unwrap().os = "linux".into();
assert_eq!(k.find_by_fp(&live).unwrap().os, "linux");
assert_eq!(k.find_by_fp(&dead).unwrap().os, "");
// An advert for a host this store has never seen writes nothing.
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
}
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
/// presentation state, so a dangling one is never an error surface.
#[test]
+27
View File
@@ -110,6 +110,21 @@ pub struct VkVideoFrame {
pub decode_done_value: u64,
pub width: u32,
pub height: u32,
/// The decode POOL's allocated extent (`AVHWFramesContext.width`/`.height`) — the
/// CODED picture size (rounded up to the codec's macroblock alignment, then to the
/// driver's Vulkan picture-access granularity), so it is `>=` `width`/`height`. At
/// 1080p the pool is 1088 rows tall: 1080 is not a multiple of 16.
///
/// The presenter samples this image with NORMALIZED coordinates, so it needs both
/// numbers — `width`/`height` is what to display, `coded_*` is what the texture
/// actually spans. Sampling `0..1` without the ratio stretches the alignment padding
/// into view; because encoders fill those rows by replicating the picture's last
/// line, that reads as the bottom row smeared over the final few rows of the image
/// (field report 2026-07-31). Same class as the D3D11VA source-rect clamp in
/// `crate::video_d3d11`, which shows as a green bar there only because DXVA padding
/// is left uninitialized rather than replicated.
pub coded_width: u32,
pub coded_height: u32,
pub color: ColorDesc,
/// Intra keyframe (IDR/I): the stream's re-anchor point. The pump resumes display on
/// one after suppressing the concealed frames a reference loss leaves in its wake (on
@@ -876,6 +891,10 @@ pub struct VulkanDecodeDevice {
/// features). The bundle now exists even without it — Windows D3D11 interop rides the
/// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`.
pub video_decode: bool,
/// The presenter has REAL on-glass present timing (`VK_KHR_present_wait` — its
/// `PresentTimer` runs). Gates the `CLIENT_CAP_PHASE_LOCK` advertisement: without a
/// true latch stamp the desktop has no latch grid and must not claim the cap.
pub present_timing: bool,
/// PyroWave decode (the wired-LAN wavelet codec) is usable: Vulkan 1.3 + the compute
/// features its kernels need were present AND enabled at device creation
/// (`shaderInt16`, `storageBuffer8BitAccess`, subgroup size control). Gates the
@@ -950,6 +969,9 @@ pub(crate) fn drm_fourcc_for(sw: ffmpeg_next::ffi::AVPixelFormat) -> Option<u32>
Some(match sw {
AV_PIX_FMT_NV12 => fourcc(b'N', b'V', b'1', b'2'),
AV_PIX_FMT_P010LE => fourcc(b'P', b'0', b'1', b'0'),
// Full-chroma 4:4:4 semi-planar (HEVC RExt decode on drivers that export it as
// two planes) — the presenter imports the full-size chroma plane like any other.
AV_PIX_FMT_NV24 => fourcc(b'N', b'V', b'2', b'4'),
_ => return None,
})
}
@@ -984,6 +1006,7 @@ mod tests {
queue_families: Vec::new(),
pyrowave_decode: false,
video_decode: true,
present_timing: false,
d3d11_import: false,
d3d11_hdr10: false,
adapter_luid: None,
@@ -1024,6 +1047,10 @@ mod tests {
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV12),
Some(0x3231_564e)
);
assert_eq!(
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV24),
Some(0x3432_564e)
);
assert_eq!(
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_RGBA),
None
+84
View File
@@ -898,3 +898,87 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32,
);
}
}
/// This desktop's HDR colour volume (`IDXGIOutput6::GetDesc1`) → the Hello's
/// `display_hdr`, so the host's virtual-display EDID matches THIS panel instead of its
/// generic defaults (host apps then tone-map to the real glass). `pos` picks the output
/// containing that desktop point — the `--window-pos` monitor, where the stream window
/// will open; no `pos` or no match falls back to the output holding the desktop origin
/// (the primary). Returns `None` when that output's advanced color is off (an SDR
/// colorspace): claiming an HDR volume for a desktop that won't present HDR would steer
/// host tone mapping wrong, and the host's EDID defaults are the honest answer there.
/// (`PUNKTFUNK_CLIENT_PEAK_NITS` still overrides whatever this reports — see
/// `punktfunk_core::client::display_hdr_env_override`.)
pub fn display_hdr_volume(pos: Option<(i32, i32)>) -> Option<punktfunk_core::quic::HdrMeta> {
use windows::Win32::dxgi::{IDXGIOutput6, DXGI_OUTPUT_DESC1};
// SAFETY: plain DXGI factory creation — no arguments to get wrong; the returned
// interface is owned by this scope and dropped with it.
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
let mut fallback: Option<DXGI_OUTPUT_DESC1> = None;
for a in 0.. {
// SAFETY: read-only enumeration on the live factory; the returned adapter is
// owned by this scope.
let Ok(adapter) = (unsafe { factory.EnumAdapters1(a) }) else {
break;
};
for o in 0.. {
// Out-pointer convention in this windows-rs rev (no retval annotation).
let mut output: Option<windows::Win32::dxgi::IDXGIOutput> = None;
// SAFETY: read-only enumeration on the live adapter, writing a local
// out-pointer that outlives the call.
if unsafe { adapter.EnumOutputs(o, &mut output) }.ok().is_err() {
break;
}
let Some(output) = output else {
break;
};
let Ok(out6) = output.cast::<IDXGIOutput6>() else {
continue; // pre-1809 DXGI — no advanced-color facts to read
};
let mut desc = DXGI_OUTPUT_DESC1::default();
// SAFETY: fills a local, correctly-sized DXGI_OUTPUT_DESC1 that outlives
// the call; the interface is live (owned just above).
if unsafe { out6.GetDesc1(&mut desc) }.ok().is_err() {
continue;
}
let r = desc.DesktopCoordinates;
let contains =
|x: i32, y: i32| x >= r.left && x < r.right && y >= r.top && y < r.bottom;
if let Some((x, y)) = pos {
if contains(x, y) {
return hdr_meta_from_output(&desc);
}
}
if fallback.is_none() || contains(0, 0) {
fallback = Some(desc);
}
}
}
hdr_meta_from_output(&fallback?)
}
/// The ST.2086 shape of one output's colour facts; `None` for an SDR colorspace.
fn hdr_meta_from_output(
d: &windows::Win32::dxgi::DXGI_OUTPUT_DESC1,
) -> Option<punktfunk_core::quic::HdrMeta> {
use windows::Win32::dxgi::DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020;
if d.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
return None;
}
// Chromaticity → 1/50000 units; luminance → 0.0001 cd/m² units (the HdrMeta contract).
let c = |v: [f32; 2]| {
[
(v[0] * 50_000.0).round().clamp(0.0, 65_535.0) as u16,
(v[1] * 50_000.0).round().clamp(0.0, 65_535.0) as u16,
]
};
Some(punktfunk_core::quic::HdrMeta {
// ST.2086 primary order is G, B, R (see the HdrMeta docs); DXGI reports R/G/B.
display_primaries: [c(d.GreenPrimary), c(d.BluePrimary), c(d.RedPrimary)],
white_point: c(d.WhitePoint),
max_display_mastering_luminance: (f64::from(d.MaxLuminance) * 10_000.0) as u32,
min_display_mastering_luminance: (f64::from(d.MinLuminance) * 10_000.0) as u32,
max_cll: d.MaxLuminance.round().clamp(0.0, 65_535.0) as u16,
max_fall: d.MaxFullFrameLuminance.round().clamp(0.0, 65_535.0) as u16,
})
}
+45 -1
View File
@@ -347,10 +347,16 @@ impl VulkanDecoder {
}
let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext;
let sw = (*fc).sw_format;
// The 2-plane layouts the presenter's CSC can sample: 4:2:0 (NV12/P010) and
// full-chroma 4:4:4 (NV24/P410 — HEVC RExt decode, semi-planar like all
// NVDEC output). The presenter's `vkframe_plane_formats` table is the final
// authority; anything else bails here so the session demotes cleanly.
if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_P010LE
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_NV24
&& sw != ffi::AVPixelFormat::AV_PIX_FMT_P410LE
{
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010 only)");
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010/NV24/P410 only)");
}
let vkfc = (*fc).hwctx as *const pf_ffvk::AVVulkanFramesContext;
let vk_format = (*vkfc).format[0] as i32;
@@ -376,6 +382,13 @@ impl VulkanDecoder {
// sem_value was last written by the decode submission on THIS thread.
let timeline_sem = (*vkf).sem[0] as u64;
let decode_done_value = (*vkf).sem_value[0];
log_layout_once(
(*self.frame).width,
(*self.frame).height,
(*fc).width,
(*fc).height,
sw,
);
Ok(VkVideoFrame {
vkframe: vkf as usize,
frames_ctx: fc as usize,
@@ -386,6 +399,13 @@ impl VulkanDecoder {
decode_done_value,
width: (*self.frame).width as u32,
height: (*self.frame).height as u32,
// The pool extent, not the frame's: `avcodec_get_hw_frames_parameters`
// sizes it from `coded_width`/`coded_height` and FFmpeg's Vulkan layer
// rounds that up again to the driver's picture-access granularity. The
// `max` is defensive — a pool SMALLER than the frame would mean sampling
// past the surface, so degrade to "no crop" rather than trust it.
coded_width: ((*fc).width.max((*self.frame).width)) as u32,
coded_height: ((*fc).height.max((*self.frame).height)) as u32,
color: ColorDesc::from_raw(self.frame),
keyframe: frame_is_keyframe(self.frame),
guard: DrmFrameGuard(clone),
@@ -394,6 +414,30 @@ impl VulkanDecoder {
}
}
/// One-time dump of the first decoded frame's layout — the forensics for a new GPU/driver.
/// `pool_*` is the allocated decode surface (`>=` the frame); the gap is the alignment
/// padding the presenter's UV scale excludes. The D3D11VA path logs the same pair.
fn log_layout_once(
width: i32,
height: i32,
pool_w: i32,
pool_h: i32,
sw: ffmpeg::ffi::AVPixelFormat,
) {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::info!(
width,
height,
pool_w,
pool_h,
?sw,
"Vulkan Video first frame"
);
}
}
impl Drop for VulkanDecoder {
fn drop(&mut self) {
use ffmpeg::ffi;
+99 -1
View File
@@ -19,35 +19,54 @@ use skia_safe::{Canvas, Rect};
enum RowId {
Resolution,
Refresh,
RenderScale,
Bitrate,
Compositor,
Codec,
Decoder,
Hdr,
Chroma444,
Audio,
Mic,
Pad,
PadType,
Touch,
Mouse,
InvertScroll,
Shortcuts,
Stats,
Fullscreen,
AutoWake,
Library,
}
const ROWS: [RowId; 14] = [
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle all
// were). Still deliberately smaller than the desktop dialogs — device pickers
// (GPU/speaker/mic) and the profile catalog stay desktop-only.
const ROWS: [RowId; 21] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
RowId::Bitrate,
RowId::Compositor,
RowId::Codec,
RowId::Decoder,
RowId::Hdr,
RowId::Chroma444,
RowId::Audio,
RowId::Mic,
RowId::Pad,
RowId::PadType,
RowId::Touch,
RowId::Mouse,
RowId::InvertScroll,
RowId::Shortcuts,
RowId::Stats,
RowId::Fullscreen,
RowId::AutoWake,
RowId::Library,
];
const RESOLUTIONS: [(u32, u32); 6] = [
@@ -59,6 +78,8 @@ const RESOLUTIONS: [(u32, u32); 6] = [
(3840, 2160),
];
const REFRESH: [u32; 5] = [0, 30, 60, 90, 120];
/// Mirrors [`punktfunk_core::render_scale::PRESETS`] (and the desktop pickers).
const RENDER_SCALES: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const BITRATES: [u32; 7] = [0, 5_000, 10_000, 20_000, 30_000, 50_000, 80_000];
const COMPOSITORS: [(&str, &str); 5] = [
("auto", "Automatic"),
@@ -76,12 +97,23 @@ const CODECS: [(&str, &str); 5] = [
// selected when the host supports it too; anything else falls back to HEVC.
("pyrowave", "PyroWave (wired LAN)"),
];
// Per-OS hardware rungs, like the shells' pickers: the console ships on Windows too
// (`punktfunk-session --browse`), where "vaapi" is a dead option that ALSO hid the real
// hardware path (d3d11va) — `Decoder::new` has no VAAPI branch there.
#[cfg(not(windows))]
const DECODERS: [(&str, &str); 4] = [
("auto", "Automatic"),
("vulkan", "Vulkan Video"),
("vaapi", "VAAPI"),
("software", "Software"),
];
#[cfg(windows)]
const DECODERS: [(&str, &str); 4] = [
("auto", "Automatic"),
("vulkan", "Vulkan Video"),
("d3d11va", "Direct3D 11"),
("software", "Software"),
];
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
const PAD_TYPES: [(&str, &str); 6] = [
("auto", "Automatic"),
@@ -114,6 +146,15 @@ impl SettingsScreen {
return None;
}
let (msg, pulse) = self.list.menu(ev, ROWS.len());
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
// screen is one of the settings file's several whole-file writers (profiles.rs
// documents the no-merge debt), and adjusting a stale snapshot would silently
// revert what another writer (a session's match-window persist, a desktop shell)
// stored while the console was open. Only on the mutating events — a cursor move
// shouldn't touch the disk.
if matches!(msg, ListMsg::Adjust(_) | ListMsg::Activate) {
*ctx.settings = pf_client_core::trust::Settings::load();
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
@@ -200,6 +241,17 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
format!("{} Hz", s.refresh_hz)
},
),
RowId::RenderScale => (
None,
"Render scale",
if s.render_scale == 1.0 {
"Native".into()
} else if s.render_scale > 1.0 {
format!("{}× (supersample)", s.render_scale)
} else {
format!("{}×", s.render_scale)
},
),
RowId::Bitrate => (
None,
"Bitrate",
@@ -221,6 +273,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
RowId::Audio => (
Some("Audio"),
"Audio channels",
@@ -254,11 +307,24 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
s.touch_mode().label().into(),
),
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
RowId::Shortcuts => (
None,
"Capture system shortcuts",
on_off(s.inhibit_shortcuts).into(),
),
RowId::Stats => (
Some("Interface"),
"Statistics overlay",
s.stats_verbosity().label().into(),
),
RowId::Fullscreen => (
None,
"Start streams fullscreen",
on_off(s.fullscreen_on_stream).into(),
),
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
};
RowSpec {
header,
@@ -278,6 +344,10 @@ fn detail(id: RowId) -> &'static str {
Match window follows this window, including mid-stream resizes."
}
RowId::Refresh => "Native follows the display this window is on.",
RowId::RenderScale => {
"The host renders larger or smaller than the stream mode and this window \
resamples above 1× supersamples, below saves bandwidth."
}
RowId::Bitrate => "Automatic uses the host's default (20 Mbps).",
RowId::Compositor => {
"Which compositor drives the virtual output — honored only if available on the host."
@@ -287,6 +357,10 @@ fn detail(id: RowId) -> &'static str {
RowId::Hdr => {
"HDR10 — engages when the host sends HDR content and this display supports it."
}
RowId::Chroma444 => {
"Full-colour video: crisp small text and thin lines, at more bandwidth. \
HEVC only, and only where the host can encode it."
}
RowId::Audio => "The speaker layout requested from the host.",
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
@@ -300,10 +374,21 @@ fn detail(id: RowId) -> &'static str {
for games), Desktop leaves it free and sends absolute positions. \
Ctrl+Alt+Shift+M switches live while streaming."
}
RowId::InvertScroll => "Reverses the wheel and trackpad scroll direction sent to the host.",
RowId::Shortcuts => {
"Alt+Tab, Super and friends reach the host while input is captured. \
Off, they act on this device instead."
}
RowId::Stats => {
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
Ctrl+Alt+Shift+S cycles it live while streaming."
}
RowId::Fullscreen => "Streams open fullscreen instead of windowed.",
RowId::AutoWake => {
"Send Wake-on-LAN to a sleeping host before connecting. Turn off for hosts \
reached over a VPN, where the wake wait only adds delay."
}
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
}
}
@@ -348,6 +433,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let cur = REFRESH.iter().position(|r| *r == s.refresh_hz);
step_option(cur, REFRESH.len(), delta, wrap).map(|i| s.refresh_hz = REFRESH[i])
}
RowId::RenderScale => {
// Exact float compare is fine: every writer (here and the desktop pickers)
// stores one of these literals; a hand-edited oddball snaps to the first step.
let cur = RENDER_SCALES.iter().position(|v| *v == s.render_scale);
step_option(cur, RENDER_SCALES.len(), delta, wrap)
.map(|i| s.render_scale = RENDER_SCALES[i])
}
RowId::Bitrate => {
let cur = BITRATES.iter().position(|b| *b == s.bitrate_kbps);
step_option(cur, BITRATES.len(), delta, wrap).map(|i| s.bitrate_kbps = BITRATES[i])
@@ -356,6 +448,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Codec => step_str(&CODECS, &mut s.codec, delta, wrap),
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
RowId::Audio => {
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
@@ -380,6 +473,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, MouseMode::ALL.len(), delta, wrap)
.map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string())
}
RowId::InvertScroll => toggle(&mut s.invert_scroll, delta, wrap),
RowId::Shortcuts => toggle(&mut s.inhibit_shortcuts, delta, wrap),
RowId::Stats => {
let cur = StatsVerbosity::ALL
.iter()
@@ -387,6 +482,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
}
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
}
.is_some()
}
+16 -3
View File
@@ -15,6 +15,14 @@
// reference white, BT.2020→709 primaries, a soft maxRGB rolloff for highlights
// (BT.2390-flavored simplicity, not libplacebo), then sRGB encode.
// params.y = tonemap peak (display-relative, ~= peak_nits / 203).
// params.zw = the crop→surface UV scale (frame size / decode-pool size). A Vulkan-Video
// pool image is the CODED surface, taller than the picture whenever the height is
// not a multiple of the driver's alignment (1080 → 1088); sampling the full 0..1
// would drag those padding rows into view — and since encoders fill them by
// replicating the last picture line, that reads as the bottom row smeared over the
// final few rows. 1.0/1.0 for every path whose image is already crop-sized (dmabuf
// imports the planes at the crop over the real stride; D3D11VA clamps in its
// VideoProcessor blit).
//
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
#version 450
@@ -29,7 +37,7 @@ layout(push_constant) uniform Csc {
vec4 r0;
vec4 r1;
vec4 r2;
vec4 params; // x: mode, y: tonemap peak, z/w: reserved
vec4 params; // x: mode, y: tonemap peak, zw: crop/pool UV scale
} pc;
// SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 =
@@ -62,17 +70,22 @@ vec3 srgb_oetf(vec3 c) {
}
void main() {
// Crop to the visible picture: the triangle spans the whole render target, so its 0..1
// maps onto the pool surface only after this scale (see params.zw above).
vec2 uv = v_uv * pc.params.zw;
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
// texels to re-align (the same correction the Apple/Windows clients apply). Self-disables
// when the plane widths match (a full-size 4:4:4 chroma plane needs no correction).
vec2 cuv = v_uv;
// textureSize is the POOL's chroma width, which is the space `uv` is already in — so the
// offset stays a true quarter-texel whatever the crop.
vec2 cuv = uv;
int cw = textureSize(u_c, 0).x;
if (cw < textureSize(u_y, 0).x) {
cuv.x += 0.25 / float(cw);
}
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg);
vec3 yuv = vec3(texture(u_y, uv).r, texture(u_c, cuv).rg);
vec3 rgb = vec3(
dot(pc.r0.xyz, yuv) + pc.r0.w,
dot(pc.r1.xyz, yuv) + pc.r1.w,
Binary file not shown.
+22 -16
View File
@@ -1,5 +1,5 @@
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12, R16/GR1616
//! for 10-bit P010) with the
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12 and full-chroma
//! NV24, R16/GR1616 for 10-bit P010) with the
//! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter
//! (`video_gl.rs`) proved on this hardware, minus the toolkit. Same-Mesa export/import
//! is the contract; anything a driver rejects surfaces as a clean error and the caller
@@ -14,6 +14,13 @@ use std::os::fd::{BorrowedFd, IntoRawFd as _};
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
/// `fourcc('P','0','1','0')` — 10-bit 4:2:0, 10 bits MSB-aligned in 16 (the HDR path).
const DRM_FORMAT_P010: u32 = 0x3031_3050;
/// `fourcc('N','V','2','4')` — 8-bit 4:4:4 semi-planar (full-size interleaved chroma
/// plane): the 2-plane full-chroma export a VAAPI HEVC RExt decode can hand over. Same
/// R8 + R8G8 views as NV12; the CSC shader keys chroma siting off the plane widths, so
/// the full-size plane needs nothing else. (Intel's iHD prefers PACKED 4:4:4 exports —
/// AYUV/Y410 — which are single-plane and would need their own CSC arm; those still
/// demote to software decode. 10-bit 4:4:4 has no settled dmabuf fourcc at all.)
const DRM_FORMAT_NV24: u32 = 0x3432_564e;
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
/// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier.
const DRM_FORMAT_MOD_LINEAR: u64 = 0;
@@ -92,13 +99,14 @@ pub fn import(
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
}
let (luma_fmt, chroma_fmt) = match frame.fourcc {
DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM),
DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM),
other => bail!("hw presenter handles NV12/P010 only (got {other:#x})"),
let (luma_fmt, chroma_fmt, chroma_full_res) = match frame.fourcc {
DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM, false),
DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM, false),
DRM_FORMAT_NV24 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM, true),
other => bail!("hw presenter handles NV12/P010/NV24 only (got {other:#x})"),
};
if frame.planes.len() < 2 {
bail!("2-plane 4:2:0 needs 2 planes (got {})", frame.planes.len());
bail!("2-plane YCbCr needs 2 planes (got {})", frame.planes.len());
}
// EGL could leave an INVALID modifier to the driver's implied choice; explicit-
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
@@ -123,16 +131,14 @@ pub fn import(
modifier,
)
.context("luma plane")?;
// 4:2:0 subsamples the chroma plane both ways; 4:4:4 (NV24) keeps it full-size.
let (cw, ch) = if chroma_full_res {
(frame.width, frame.height)
} else {
(frame.width.div_ceil(2), frame.height.div_ceil(2))
};
let (chroma_img, chroma_mem) = match plane_image(
device,
ext_mem_fd,
frame.width.div_ceil(2),
frame.height.div_ceil(2),
chroma_fmt,
c.fd,
c.offset,
c.stride,
modifier,
device, ext_mem_fd, cw, ch, chroma_fmt, c.fd, c.offset, c.stride, modifier,
)
.context("chroma plane")
{
+104 -2
View File
@@ -193,6 +193,10 @@ struct StreamState {
/// The settings profile this session resolved with, for the stats overlay's first line
/// ("which profile am I on?"). `None` = the global defaults, and nothing is shown.
profile: Option<String>,
/// The latch grid the pump's PhaseReports read (see `session::LatchGrid`), written by
/// the 1 Hz present-timing fold. `None` = the session didn't advertise phase lock
/// (no present-wait, or an embedder that opted out).
latch_grid: Option<Arc<session::LatchGrid>>,
/// Live host↔client clock offset handle (None until Connected): loaded per present so
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
@@ -269,6 +273,10 @@ impl StreamState {
wake: sdl3::event::EventSender,
) -> StreamState {
let profile = params.profile.clone();
// The presenter's half of phase-locked capture: it writes the latch grid the
// pump reads (see `LatchGrid`), so keep the Arc before the params move. `None`
// when the session didn't advertise the cap — the 1 Hz fold then skips the work.
let latch_grid = params.phase_lock.then(|| params.latch_grid.clone());
let handle = session::start(params);
let (wake_tx, wake_rx) = async_channel::bounded(2);
let pump_rx = handle.frames.clone();
@@ -294,6 +302,7 @@ impl StreamState {
ready_announced: false,
mode_line: String::new(),
profile,
latch_grid,
clock_offset: None,
hdr: false,
win_e2e_us: Vec::with_capacity(256),
@@ -1458,7 +1467,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
for s in presenter.take_presented_samples() {
let samples = presenter.take_presented_samples();
// Phase-locked capture, the presenter's half: publish this window's latch
// grid — a recent TRUE on-glass instant plus the panel period — for the
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
// by the display mode's refresh — under arrival-paced MAILBOX a stream
// running below the panel rate spaces its presents at k×period, and the
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
if let Some(grid) = &st.latch_grid {
if let Some(last) = samples.last() {
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
let min_delta = samples
.windows(2)
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
.min()
.unwrap_or(refresh_period);
grid.period_ns
.store(min_delta.min(refresh_period), Ordering::Relaxed);
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
}
}
for s in samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
@@ -1489,6 +1520,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let browse_idle = matches!(mode, ModeCtl::Browse(_))
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
if !presented_video && (resize_scrim || browse_idle) {
// The UI owns the screen again: hand the swapchain back to SDR before drawing
// it. A finished PQ stream leaves HDR10 live, and nothing else would ever turn
// it off — `present` re-evaluates the mode only from a frame's colour
// signalling, and these UI presents carry no frame. Guarded inside, so this is
// free on every ordinary idle iteration. (Deliberately NOT applied to
// `resize_scrim`: that scrim is a mid-stream gap in a session that is still
// HDR, and flipping there would rebuild the swapchain twice per resize.)
if browse_idle {
presenter.leave_hdr(&window)?;
}
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
};
@@ -1976,8 +2017,27 @@ fn stats_text(
}
let detailed = verbosity == StatsVerbosity::Detailed;
let mut text = if detailed {
// The encoder target next to the measured rate is the figure whose absence let the
// settings-drop bug ship four releases: "19 Mb/s" alone can't distinguish "the
// encoder is capped at 20" from "my 200 Mb/s grant met a cheap scene". `(auto)`
// marks an Automatic session — the ABR moves the target by design, so a shifting
// number reads as policy, not a broken setting. Omitted against an old host that
// never reported a rate.
let target = match (s.target_kbps, s.auto_rate) {
(0, _) => String::new(),
(t, true) => format!(" · target {:.0} Mb/s (auto)", f64::from(t) / 1000.0),
(t, false) => format!(" · target {:.0} Mb/s", f64::from(t) / 1000.0),
};
// The chroma tag mirrors the HDR tag's honesty: `4:4:4→4:2:0` = the session asked
// for full chroma and the host resolved 4:2:0 (its policy/capturer/encoder gates
// said no) — otherwise the Settings switch's effect is unobservable.
let chroma = match (s.asked_444, s.chroma_444) {
(_, true) => " · 4:4:4",
(true, false) => " · 4:4:4→4:2:0",
_ => "",
};
format!(
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
"{mode_line} · {:.0} fps · {:.1} Mb/s{target} · {}{}{chroma}",
s.fps,
s.mbps,
if s.decoder.is_empty() { "-" } else { s.decoder },
@@ -2263,6 +2323,12 @@ mod tests {
lost: 3,
lost_pct: 0.4,
decoder: "vulkan",
// Old-host baseline (no reported target, 4:2:0 never asked): the tier
// texts stay exactly what they were before the target/chroma elements.
target_kbps: 0,
auto_rate: false,
chroma_444: false,
asked_444: false,
},
PresentedWindow {
e2e_p50_ms: 6.4,
@@ -2303,6 +2369,42 @@ mod tests {
);
}
/// Detailed shows the negotiated encoder target next to the measured rate — the
/// figure whose absence let the settings-drop bug ship four releases — tagged
/// `(auto)` when the ABR owns it, plus the honest chroma tag when 4:4:4 was asked.
#[test]
fn detailed_shows_target_and_chroma_resolution() {
let (mut s, p) = sample();
let line1 = |s: &Stats, v| {
stats_text(v, "m", s, &p, false, false, None)
.lines()
.next()
.unwrap()
.to_string()
};
// Explicit 200 Mb/s honoured, cheap scene: measured AND target both show — the
// exact pair a user needs to tell a capped encoder from an idle one.
s.target_kbps = 200_000;
assert!(line1(&s, StatsVerbosity::Detailed).contains("24.3 Mb/s · target 200 Mb/s · "));
// An Automatic session's moving target reads as policy, not a broken setting.
(s.target_kbps, s.auto_rate) = (20_000, true);
assert!(line1(&s, StatsVerbosity::Detailed).contains("target 20 Mb/s (auto)"));
// Normal keeps its old line — the target is a Detailed element.
assert!(!line1(&s, StatsVerbosity::Normal).contains("target"));
// An old host that never reported a rate shows no target element at all.
s.target_kbps = 0;
assert!(!line1(&s, StatsVerbosity::Detailed).contains("target"));
// 4:4:4 asked and granted…
(s.asked_444, s.chroma_444) = (true, true);
assert!(line1(&s, StatsVerbosity::Detailed).ends_with("· 4:4:4"));
// …vs asked and declined: the downgrade is said out loud, mirroring `HDR→SDR`.
s.chroma_444 = false;
assert!(line1(&s, StatsVerbosity::Detailed).ends_with("· 4:4:4→4:2:0"));
// Unasked stays untagged (4:2:0 is the default — not noise worth a tag).
s.asked_444 = false;
assert!(!line1(&s, StatsVerbosity::Detailed).contains("4:4:4"));
}
/// Compact omits the latency term until the presenter's first e2e window lands.
#[test]
fn compact_waits_for_e2e() {
+93 -12
View File
@@ -248,9 +248,12 @@ impl Presenter {
height: v.height,
};
let ten_bit = f.is_p010();
// No crop: `dmabuf::import` already creates the plane images at the frame
// size over the surface's real stride, so 0..1 spans exactly the picture.
self.record_csc(
v.framebuffer,
extent,
[1.0, 1.0],
f.color,
if ten_bit { 10 } else { 8 },
ten_bit,
@@ -322,9 +325,17 @@ impl Presenter {
};
let ten_bit =
f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw();
// The one path that samples a surface BIGGER than the picture: FFmpeg's
// pool is the coded size (1080 → 1088 rows). Scale the UVs to the visible
// crop or the alignment padding — the last picture row, replicated by the
// encoder — is stretched into the bottom of the image.
self.record_csc(
v.framebuffer,
extent,
[
f.width as f32 / f.coded_width as f32,
f.height as f32 / f.coded_height as f32,
],
f.color,
if ten_bit { 10 } else { 8 },
ten_bit,
@@ -625,7 +636,11 @@ impl Presenter {
/// Record the NV12→RGBA CSC pass into the video image (framebuffer): fullscreen
/// triangle, CICP-driven push-constant rows. Shared by the dmabuf and Vulkan-Video
/// paths — only the plane views bound beforehand differ.
/// paths — only the plane views bound beforehand, and `uv_scale`, differ.
///
/// `extent` is the picture (the framebuffer's own size); `uv_scale` is picture/surface
/// per axis, i.e. `[1.0, 1.0]` unless the bound planes are a decode pool allocated
/// larger than the picture. See the shader's `params.zw` for why that happens.
///
/// # Safety
/// `self.cmd_buf` must be in the recording state; the CSC descriptor set must point
@@ -634,6 +649,7 @@ impl Presenter {
&self,
framebuffer: vk::Framebuffer,
extent: vk::Extent2D,
uv_scale: [f32; 2],
color: pf_client_core::video::ColorDesc,
depth: u8,
msb_packed: bool,
@@ -702,6 +718,9 @@ impl Presenter {
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[12] = mode;
pc[13] = peak;
// Crop: 1.0 unless the source image is a decode pool bigger than the picture.
pc[14] = uv_scale[0];
pc[15] = uv_scale[1];
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
self.device.cmd_push_constants(
self.cmd_buf,
@@ -815,19 +834,12 @@ impl Presenter {
/// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's
/// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this).
/// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6).
/// See [`vkframe_plane_formats`] for the accepted pool formats.
fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> {
let (luma_fmt, chroma_fmt) = if f.vk_format == vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw()
{
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
} else if f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw() {
(
vk::Format::R10X6_UNORM_PACK16,
vk::Format::R10X6G10X6_UNORM_2PACK16,
)
} else {
let Some((luma_fmt, chroma_fmt)) = vkframe_plane_formats(f.vk_format) else {
bail!(
"Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0, 8/10-bit)",
"Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0 or 4:4:4, \
8/10-bit 3-plane layouts need a third CSC binding)",
f.vk_format
);
};
@@ -875,6 +887,36 @@ impl Presenter {
}
}
/// The (luma, chroma) per-plane view formats for a Vulkan-Video pool format, or `None`
/// when this presenter can't sample it (the caller bails; the decoder demotes to
/// software — never a black screen).
///
/// The decision table IS the desktop 4:4:4 display contract, so it's a pure function
/// with a test pinning it:
/// - 2-plane 4:2:0, 8-bit (NV12-layout) and 10-bit (P010/X6) — the classic pair.
/// - 2-plane 4:4:4, 8- and 10-bit — what NVIDIA's Vulkan Video reports for HEVC RExt
/// full-chroma decode (semi-planar, like all NVDEC output). The CSC shader already
/// handles the full-size chroma plane (its 4:2:0 siting correction self-disables when
/// the plane widths match), so accepting the format here is all hardware 4:4:4 needs.
/// - 3-plane 4:4:4 stays rejected: the CSC pass samples exactly two planes (luma +
/// interleaved chroma); a triplanar pool needs a third binding + shader variant. No
/// supported driver reports it for HEVC decode today — revisit when one does.
fn vkframe_plane_formats(raw: i32) -> Option<(vk::Format, vk::Format)> {
let eight = (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM);
let ten = (
vk::Format::R10X6_UNORM_PACK16,
vk::Format::R10X6G10X6_UNORM_2PACK16,
);
[
(vk::Format::G8_B8R8_2PLANE_420_UNORM, eight),
(vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16, ten),
(vk::Format::G8_B8R8_2PLANE_444_UNORM, eight),
(vk::Format::G10X6_B10X6R10X6_2PLANE_444_UNORM_3PACK16, ten),
]
.into_iter()
.find_map(|(f, planes)| (f.as_raw() == raw).then_some(planes))
}
/// Flatten the 3×vec4 rows for the push-constant block.
fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] {
// SAFETY: [[f32;4];3] is 12 contiguous f32s.
@@ -934,3 +976,42 @@ fn unlock_vkframe(f: &VkVideoFrame, sync: &VkFrameSync, submitted: bool, graphic
unlock(f.frames_ctx as *mut pf_ffvk::AVHWFramesContext, vkf);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The pool-format decision table (what this presenter can sample → what stays on the
/// hardware path, everything else demotes to software decode). Pinned so a format
/// added or dropped here is a deliberate act, not a drive-by.
#[test]
fn vkframe_pool_format_decision_table() {
let eight = Some((vk::Format::R8_UNORM, vk::Format::R8G8_UNORM));
let ten = Some((
vk::Format::R10X6_UNORM_PACK16,
vk::Format::R10X6G10X6_UNORM_2PACK16,
));
// 2-plane 4:2:0, both depths — the classic pair.
let f = |fmt: vk::Format| vkframe_plane_formats(fmt.as_raw());
assert_eq!(f(vk::Format::G8_B8R8_2PLANE_420_UNORM), eight);
assert_eq!(
f(vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16),
ten
);
// 2-plane 4:4:4, both depths — hardware full-chroma (NVIDIA RExt decode). Same
// per-plane view formats; the full-size chroma plane is the shader's business.
assert_eq!(f(vk::Format::G8_B8R8_2PLANE_444_UNORM), eight);
assert_eq!(
f(vk::Format::G10X6_B10X6R10X6_2PLANE_444_UNORM_3PACK16),
ten
);
// 3-plane 4:4:4 and 2-plane 4:2:2: real formats a driver could report, NOT
// sampleable by the two-binding CSC — they must demote, not corrupt.
assert_eq!(f(vk::Format::G8_B8_R8_3PLANE_444_UNORM), None);
assert_eq!(f(vk::Format::G8_B8R8_2PLANE_422_UNORM), None);
assert_eq!(f(vk::Format::G16_B16R16_2PLANE_444_UNORM), None);
// Garbage never maps.
assert_eq!(vkframe_plane_formats(0), None);
assert_eq!(vkframe_plane_formats(-1), None);
}
}
+28
View File
@@ -171,6 +171,34 @@ impl Presenter {
self.hdr_active
}
/// Drop back to the SDR swapchain. A no-op unless HDR10 is actually live, so it is
/// cheap to call on every idle iteration (the flip itself rebuilds the CSC pass, the
/// video image, the overlay pipe and the swapchain).
///
/// The console/gamepad UI is SDR content, and it is composited into whatever swapchain
/// the last STREAM left behind. [`Presenter::present`] only re-evaluates the mode when a
/// frame carries colour signalling, and a UI-only present is `FrameInput::Redraw`, which
/// carries none — so once a PQ session ended, the UI kept being written into the HDR10
/// swapchain and its sRGB mid-tones were emitted as PQ code points, i.e. near-peak nits.
/// That is the "gamepad UI is blown out after disconnecting from an HDR host" report:
/// the UI looks right until the first HDR session, and wrong forever after.
pub fn leave_hdr(&mut self, window: &sdl3::video::Window) -> Result<()> {
if !self.hdr_active {
return Ok(());
}
// Minimized is not just "pointless work": `recreate_swapchain` deliberately keeps
// the old swapchain at a zero extent, but `set_hdr_mode` would already have rebuilt
// the CSC and overlay pipes against the SDR format — leaving them mismatched against
// the live HDR10 swapchain images. [`Presenter::present`] carries the same guard,
// which is why the flip could never reach this state before; the mode change just
// waits for the window to have a size again.
if self.extent.width == 0 || self.extent.height == 0 {
return Ok(());
}
tracing::info!("stream over — leaving HDR10 so the console UI composites as SDR");
self.set_hdr_mode(window, false)
}
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
/// idempotent per distinct value — callers just drain the plane into it.
+3
View File
@@ -424,6 +424,9 @@ impl Presenter {
queue_families: queue_info.iter().map(|q| q.queue_family_index).collect(),
pyrowave_decode: pyrowave_ok,
video_decode: video_ok,
// The phase-lock gate: real on-glass latch stamps exist only when the
// present-wait timer runs (see `PresentTimer`).
present_timing: present_timer.is_some(),
#[cfg(windows)]
d3d11_import: win_capable,
#[cfg(not(windows))]
+20
View File
@@ -166,6 +166,12 @@ pub struct NativeClient {
/// drained per window by the data-plane pump to feed the adaptive-bitrate controller's decode
/// signal. Shared with the pump; see [`DecodeLatAcc`].
decode_lat: Arc<Mutex<DecodeLatAcc>>,
/// The encoder's CURRENT target bitrate (kbps): seeded with the Welcome resolve, then updated
/// by every host `BitrateChanged` ack (the ABR's re-targets, host-side clamps). Where
/// [`resolved_bitrate_kbps`](Self::resolved_bitrate_kbps) is the session-start negotiation
/// frozen for the ABI, this one moves — it's what a stats HUD should print as "target".
/// `0` = an old host that never reported a rate.
live_bitrate_kbps: Arc<AtomicU32>,
/// Whether the adaptive-bitrate controller is armed for this session (Automatic bitrate and not
/// a rate-pinned PyroWave stream) — exposed via [`wants_decode_latency`](Self::wants_decode_latency)
/// so an embedder skips the per-frame decode measurement when the controller wouldn't use it.
@@ -399,6 +405,8 @@ impl NativeClient {
let hot_tids = Arc::new(Mutex::new(Vec::new()));
let clock_offset = Arc::new(AtomicI64::new(0));
let decode_lat = Arc::new(Mutex::new(DecodeLatAcc::default()));
// Seeded by the pump from the Welcome (before ready_tx), then follows every ack.
let live_bitrate = Arc::new(AtomicU32::new(0));
let host = host.to_string();
let frame_chan_w = frame_chan.clone();
@@ -411,6 +419,7 @@ impl NativeClient {
let hot_tids_w = hot_tids.clone();
let clock_offset_w = clock_offset.clone();
let decode_lat_w = decode_lat.clone();
let live_bitrate_w = live_bitrate.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
.name("punktfunk-client".into())
@@ -475,6 +484,7 @@ impl NativeClient {
hot_tids: hot_tids_w,
clock_offset: clock_offset_w,
decode_lat: decode_lat_w,
live_bitrate: live_bitrate_w,
}));
})
.map_err(PunktfunkError::Io)?;
@@ -523,6 +533,7 @@ impl NativeClient {
hot_tids,
clock_offset,
decode_lat,
live_bitrate_kbps: live_bitrate,
// The controller arms exactly when the pump does — all three terms, not two: Automatic
// (the user asked for bitrate 0), not a rate-pinned PyroWave stream, AND the host
// echoed the rate it actually configured. Dropping the last term made this
@@ -780,6 +791,15 @@ impl NativeClient {
self.wants_decode
}
/// The encoder's CURRENT target bitrate (kbps): the Welcome-resolved rate, live-updated by
/// every host `BitrateChanged` ack — an Automatic session's ABR re-targets and the host's
/// own clamps included. This is the figure a stats HUD should show as "target" next to
/// measured throughput (the [`resolved_bitrate_kbps`](Self::resolved_bitrate_kbps) field
/// stays the frozen session-start value). `0` = an old host that never reported one.
pub fn current_bitrate_kbps(&self) -> u32 {
self.live_bitrate_kbps.load(Ordering::Relaxed)
}
/// Report this client's display-latch grid so the host can phase-lock its capture tick
/// (design/phase-locked-capture.md; the vsync-aware presenters call this ~1 Hz).
/// `next_latch_host_ns` must already be HOST clock — convert with
+5
View File
@@ -71,6 +71,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
hot_tids,
clock_offset,
decode_lat,
live_bitrate,
..
} = args;
// Copies the pump needs after `negotiated` is handed over to `connect`.
@@ -80,6 +81,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
// Same discipline for the live encoder target: the Welcome resolve is the starting truth
// (0 against an old host that reports none); every BitrateChanged ack moves it from there.
live_bitrate.store(negotiated.bitrate_kbps, Ordering::Relaxed);
// Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
// reset its staleness counters and re-arm the clock-based jump-to-live detector.
let clock_gen = Arc::new(AtomicU32::new(0));
@@ -134,6 +138,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
mode_slot,
probe: probe.clone(),
bitrate_ack: bitrate_ack.clone(),
live_bitrate,
recovery_kf: recovery_kf.clone(),
clock_offset: clock_offset.clone(),
clock_gen: clock_gen.clone(),
@@ -16,6 +16,9 @@ pub(super) struct ControlTask {
pub(super) probe: Arc<Mutex<ProbeState>>,
/// The latest host `BitrateChanged` ack, drained by the pump's ABR on its report tick.
pub(super) bitrate_ack: Arc<Mutex<Option<u32>>>,
/// The live encoder-target mirror ([`NativeClient::current_bitrate_kbps`]): unlike the
/// drain-once ack slot above, this one always holds the latest acked rate for stats HUDs.
pub(super) live_bitrate: Arc<AtomicU32>,
/// Outbound decode-recovery KEYFRAME asks, counted here because this is the one choke point
/// every emitter funnels through (embedder, `note_frame_index`, the pump's own asks) — the
/// pump drains the count per report window as the ABR's recovery signal.
@@ -44,6 +47,7 @@ impl ControlTask {
mode_slot,
probe,
bitrate_ack,
live_bitrate,
recovery_kf,
clock_offset,
clock_gen,
@@ -157,6 +161,11 @@ impl ControlTask {
kbps = ack.bitrate_kbps,
"host re-targeted encoder bitrate"
);
// 0 would be a nonsense ack (the controller ignores it too) — don't
// let it wipe the HUD's live target.
if ack.bitrate_kbps > 0 {
live_bitrate.store(ack.bitrate_kbps, Ordering::Relaxed);
}
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) {
+4 -1
View File
@@ -6,7 +6,7 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::Result;
use crate::input::InputEvent;
use crate::quic::{HdrMeta, HidOutput};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Mutex};
@@ -73,6 +73,9 @@ pub(crate) struct WorkerArgs {
/// Decode-stage latency samples from the embedder (see [`NativeClient::decode_lat`]): the pump
/// drains a window mean into the adaptive-bitrate controller's decode signal.
pub(crate) decode_lat: Arc<Mutex<DecodeLatAcc>>,
/// The live encoder-target mirror (see [`NativeClient::live_bitrate_kbps`]): the worker seeds
/// it from the Welcome; the control task updates it on every `BitrateChanged` ack.
pub(crate) live_bitrate: Arc<AtomicU32>,
}
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
@@ -326,14 +326,15 @@ pub(super) async fn negotiate(
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
// gate. Linux's portal capturer always can (`capturer_supports_444` returns `true`
// unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes
// the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend
// that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
// unconditionally). On WINDOWS the IDD-push path CAN too, at either depth: an SDR session
// passes the BGRA ring slot straight through and an HDR one converts the FP16 desktop to
// packed 10-bit BT.2020 PQ RGB — both skip the subsampling converters. Only a backend that
// ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards
// `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0
// at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check
// reports that truth. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.)
// the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). HDR no longer costs the chroma: 10-bit
// 4:4:4 is HEVC Main 4:4:4 10, which is what this resolves to. (Replaces the old
// `single_process` gate — single-process is now the only topology, and 4:4:4 routed to DDA,
// which was removed.)
// PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma
// (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real
// gate is `can_encode_444` (the full-res-chroma CSC variant existing on this OS).
+16 -7
View File
@@ -45,8 +45,9 @@ captured input, switch mouse mode, disconnect — are in
**Compact** is a one-line pill (fps · end-to-end ms · Mb/s, plus a loss flag when frames are being
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage
breakdown everywhere; on Linux/Windows it also adds the decode path and an HDR tag, on Android the
decoder plus the full codec/bit-depth/colour line, and on iOS/tvOS the excluded OS present floor.
breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path,
an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and
on iOS/tvOS the excluded OS present floor.
You can also set the level a stream starts at in each client's
[Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view.
@@ -60,7 +61,7 @@ Every client reports the same measurements, but each family lays them out a litt
differently. Linux · Windows · Steam Deck:
```
1920×1080@120 · 120 fps · 24.3 Mb/s · vulkan · HDR
1920×1080@120 · 120 fps · 24.3 Mb/s · target 30 Mb/s (auto) · vulkan · HDR
e2e 14.2/19.8 ms (p50/p95) · host 3.1 · net 6.7 · decode 2.1 · display 2.3 ms
host: queue 0.6 · encode 1.8 · xfer 0.2 · pace 0.5 ms
lost 3 (2.4%)
@@ -90,10 +91,18 @@ lost 3 (2.4%)
```
- **Line 1 — the stream.** Resolution@refresh, frames received per second, and the
received video bitrate (goodput — FEC overhead not counted). Linux/Windows append the
decode path and an [HDR](/docs/hdr) tag (`HDR`, or `HDR→SDR` when a PQ stream is tone-mapped onto
an SDR screen); Android puts its decoder and the negotiated codec, bit depth, colour and
chroma on rows of their own underneath; the Apple clients don't report a codec at all.
received video bitrate (goodput — FEC overhead not counted). Linux/Windows follow the
measured rate with `target N Mb/s` — what the host's encoder is currently *allowed* to
produce — so a quiet desktop under a large grant (measured far below target) reads
differently from an encoder pinned at its cap (measured hugging the target). `(auto)`
means the [Automatic bitrate](/docs/client-settings#bitrate) controller owns the target
and moves it with network conditions; no target at all means an older host that doesn't
report one. Then the decode path, an [HDR](/docs/hdr) tag (`HDR`, or `HDR→SDR` when a PQ
stream is tone-mapped onto an SDR screen), and — when you asked for
[full chroma](/docs/client-settings) — the resolved chroma: `4:4:4` when the host
granted it, `4:4:4→4:2:0` when it couldn't. Android puts its decoder and the negotiated
codec, bit depth, colour and chroma on rows of their own underneath; the Apple clients
don't report a codec at all.
If the session resolved to a [settings profile](/docs/profiles-and-links), its name closes this
line. On **Android** a `⚠ panel NN Hz` warning joins it whenever the device's panel is refreshing
*below* the stream's rate — the tell for a phone or TV governor that ignored the requested mode,
+24 -7
View File
@@ -31,9 +31,20 @@ if (-not (Test-Path $appWeb)) {
}
Write-Host "swapping $appWeb\.output (stopping the PunktfunkHost service) ..."
$dst = Join-Path $appWeb '.output'
& net stop PunktfunkHost | Out-Null
try {
Remove-Item (Join-Path $appWeb '.output') -Recurse -Force -ErrorAction SilentlyContinue
# The removal MUST succeed before the copy. A merge of two builds is not a degraded
# install, it is a dead one: Nitro's entry.mjs imports its siblings by content hash, so a
# stale chunks/_ next to a new entry.mjs makes every page 200 with a bun ResolveMessage
# body instead of the app. Observed on .173 2026-07-31 (an older task-based copy of this
# script could not unlock the files under the supervised-child host, its
# -ErrorAction SilentlyContinue swallowed that, and the console served a JSON error for
# hours while the probe below reported success).
Remove-Item $dst -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $dst) {
throw "could not remove $dst (files still locked - is another bun/host still running?). Refusing to copy over it: a mixed .output serves errors, not the console."
}
Copy-Item (Join-Path $web '.output') -Destination $appWeb -Recurse -Force
}
finally {
@@ -43,14 +54,20 @@ finally {
# The console serves HTTPS-only (PUNKTFUNK_UI_SECURE=1, the host's own cert) - probe with curl.exe
# (-k for the self-signed cert; Invoke-WebRequest under Windows PowerShell 5.1, which this script
# runs under, has no -SkipCertificateCheck), retrying while the service/bun cold-starts.
$code = $null
#
# The BODY is the check, not the status code: a bun that started but cannot resolve its own
# chunks answers 200 with a ResolveMessage JSON, so a code-only probe reports a healthy
# console that serves nothing but an error (exactly how the .173 breakage stayed invisible).
$body = $null
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep 2
$code = & curl.exe -sk -o NUL -w '%{http_code}' --max-time 5 'https://127.0.0.1:47992/login' 2>$null
if ($code -eq '200') { break }
$body = & curl.exe -sk --max-time 5 'https://127.0.0.1:47992/login' 2>$null
if ($body -match '<html|<!DOCTYPE html') { break }
}
if ($code -eq '200') {
Write-Host "DONE - web /login -> HTTP $code"
if ($body -match '<html|<!DOCTYPE html') {
Write-Host "DONE - the console serves the app (/login returned HTML)"
} elseif ($body -match 'Cannot find module|ResolveMessage') {
Write-Error "BROKEN - /login answered with a bun module-resolution error, i.e. .output is inconsistent: $body"
} else {
Write-Warning "console swapped but /login did not return 200 yet (last: $code) - check %ProgramData%\punktfunk\logs\web.log"
Write-Warning "console swapped but /login did not serve HTML yet - check %ProgramData%\punktfunk\logs\web.log. Last body: $body"
}
+4 -3
View File
@@ -35,8 +35,9 @@ WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
# loopback :47990, no token (a token is mandatory for non-loopback binds).
```
If the host runs with `--mgmt-token`, set it under **Settings → API token** (stored in
`localStorage`, sent as `Authorization: Bearer …` by the orval fetcher).
The management token is **server-side only** — set `PUNKTFUNK_MGMT_TOKEN` in the console's
environment and the BFF injects it when proxying (`server/routes/api/[...].ts`). It never reaches
the browser, so there is no token field in the UI; the browser only ever holds the session cookie.
## Build & run (Nitro + Bun)
@@ -115,7 +116,7 @@ src/
app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher
brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs)
ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton)
query-state.tsx loading/error wrapper (incl. 401 → "set a token")
query-state.tsx loading/error wrapper (401 → the session is gone, re-login)
api/
fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError
gen/ GENERATED react-query hooks + models (orval)
+102 -10
View File
@@ -6,7 +6,6 @@
"nav_dashboard": "Übersicht",
"nav_host": "Host",
"nav_displays": "Virtuelle Anzeigen",
"nav_clients": "Gekoppelte Geräte",
"nav_pairing": "Kopplung",
"nav_library": "Bibliothek",
"nav_plugins": "Plugins",
@@ -14,7 +13,49 @@
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
"nav_automation": "Automatisierung",
"automation_title": "Automatisierung",
"automation_subtitle": "Führe einen Befehl aus oder rufe einen Webhook auf, wenn auf diesem Host etwas passiert.",
"automation_hooks_title": "Ereignis-Hooks",
"automation_add": "Hook hinzufügen",
"automation_empty": "Noch keine Hooks. Füge einen hinzu, um etwas auszuführen, wenn ein Stream startet, ein Gerät koppelt oder ein Spiel endet.",
"automation_edit": "Hook bearbeiten",
"automation_delete": "Hook löschen",
"automation_delete_confirm": "Diesen Hook löschen?",
"automation_unsaved": "Nicht gespeicherte Änderungen",
"automation_saved": "Automatisierung gespeichert",
"automation_save_failed": "Die Automatisierung konnte nicht gespeichert werden.",
"automation_debounce_badge": "mind. {ms} ms Abstand",
"automation_hook_title": "Hook",
"automation_hook_help": "Wähle das Ereignis und dann, was passieren soll. Ein abschließendes .* trifft alle Ereignisse dieser Domäne.",
"automation_hook_save": "Fertig",
"automation_field_on": "Wenn",
"automation_field_on_help": "Das Ereignis, das diesen Hook auslöst — dieselben Namen, die der Host auf seinem Event-Stream veröffentlicht.",
"automation_field_action": "Dann",
"automation_action_run": "Befehl ausführen",
"automation_action_webhook": "Webhook aufrufen",
"automation_action_run_help": "Läuft losgelöst als Host-Benutzer, mit dem Ereignis-JSON auf stdin und PF_EVENT_* in der Umgebung.",
"automation_action_webhook_help": "Das Ereignis-JSON wird per POST an diese URL geschickt.",
"automation_field_hmac": "HMAC-Schlüsseldatei (optional)",
"automation_field_hmac_help": "Eine private, dem Betreiber gehörende Datei mit dem Signaturschlüssel. Die Anfrage trägt X-Punktfunk-Signature, damit der Empfänger prüfen kann, dass sie wirklich von diesem Host kommt.",
"automation_field_filter": "Nur für ein bestimmtes Gerät oder Spiel",
"automation_filter_client": "Gerätename",
"automation_filter_app": "Spiel-/App-ID",
"automation_field_debounce": "Mindestabstand (ms)",
"automation_field_timeout": "Zeitlimit (s)",
"automation_confirm_title": "Automatisierung speichern?",
"automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.",
"library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.",
"gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.",
"stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.",
"stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.",
"stats_delete_failed": "Diese Aufzeichnung konnte nicht gelöscht werden.",
"stats_download_failed": "Diese Aufzeichnung konnte nicht heruntergeladen werden.",
"games_end_failed": "Das Spiel konnte nicht beendet werden.",
"action_stop_failed": "Die Sitzung konnte nicht beendet werden.",
"action_idr_failed": "Es konnte kein Keyframe angefordert werden.",
"nav_settings": "Einstellungen",
"nav_close_menu": "Menü schließen",
"nav_more": "Mehr",
"status_title": "Live-Status",
"status_video": "Video",
@@ -25,14 +66,47 @@
"status_sessions_active": "{count} aktiv",
"status_no_session": "Keine aktive Sitzung",
"status_paired_count": "Gekoppelte Geräte",
"status_pin_waiting": "Wartet",
"status_pin_none": "Keine",
"status_pin_pending": "Kopplungs-PIN ausstehend",
"stream_codec": "Codec",
"stream_resolution": "Auflösung",
"stream_fps": "Bildrate",
"stream_first_frame": "Erstes Bild",
"stream_last_resize": "Letzte Größenänderung",
"stream_packet_size": "Paketgröße",
"stream_min_fec": "FEC-Minimum",
"stream_bitrate": "Bitrate",
"activity_title": "Letzte Aktivität",
"activity_empty": "Noch nichts — Ereignisse erscheinen hier, sobald sie auf dem Host passieren.",
"activity_client_connected": "Verbunden",
"activity_client_disconnected": "Getrennt",
"activity_session_started": "Sitzung gestartet",
"activity_session_ended": "Sitzung beendet",
"activity_stream_started": "Stream gestartet",
"activity_stream_stopped": "Stream gestoppt",
"activity_game_running": "Spiel läuft",
"activity_game_exited": "Spiel beendet",
"activity_pairing_pending": "Kopplung angefragt",
"activity_pairing_completed": "Gekoppelt",
"activity_pairing_denied": "Kopplung abgelehnt",
"activity_display_created": "Anzeige erstellt",
"activity_display_released": "Anzeige freigegeben",
"activity_library_changed": "Bibliothek geändert",
"activity_update_available": "Update verfügbar",
"activity_update_applied": "Update angewendet",
"activity_plugins_changed": "Plugins geändert",
"activity_store_changed": "Store geändert",
"activity_host_started": "Host gestartet",
"activity_host_stopping": "Host wird beendet",
"action_stop_session": "Sitzung beenden",
"action_request_idr": "Keyframe anfordern",
"action_unpair": "Entkoppeln",
"connect_title": "Gerät verbinden",
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
"connect_address": "Host-Adresse",
"connect_link": "Deep-Link",
"connect_copy": "Kopieren",
"host_identity": "Identität",
"host_hostname": "Hostname",
"host_os": "Betriebssystem",
@@ -60,7 +134,8 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
"host_displays": "Virtuelle Displays",
"host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server",
"host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
"display_preset": "Voreinstellung",
@@ -90,6 +165,7 @@
"display_state_lingering": "Wird gehalten",
"display_state_pinned": "Angeheftet",
"display_release_btn": "Freigeben",
"display_refresh_failed": "Aktualisierung vom Host fehlgeschlagen — es werden die zuletzt bekannten Einstellungen gezeigt. Deine Änderungen bleiben erhalten.",
"display_release_all": "Alle gehaltenen freigeben",
"display_expires_in": "Abbau in {sec}s",
"display_sessions": "{count} streamend",
@@ -97,6 +173,7 @@
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
"display_arrange_save": "Anordnung speichern",
"display_custom_desc": "Jede Option selbst festlegen.",
"display_preset_apply_named": "Voreinstellung {name} anwenden",
"display_preset_current": "Aktiv",
"display_preset_soon": "in Kürze",
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
@@ -144,11 +221,8 @@
"display_all_saved": "Alle Änderungen gespeichert",
"display_revert": "Änderungen verwerfen",
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
"clients_fingerprint": "Fingerabdruck",
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_title": "Kopplung",
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
@@ -186,6 +260,7 @@
"library_store_steam": "Steam",
"library_store_custom": "Eigene",
"library_add_title": "Eigenes Spiel hinzufügen",
"library_edit_overwrites": "Beim Speichern wird dieser Eintrag durch das ersetzt, was in diesem Formular steht. Vorbereitungs-/Undo-Befehle und Erkennungs-Hinweise, die außerhalb der Konsole gesetzt wurden, erscheinen hier nicht und gehen verloren.",
"library_edit_title": "Eigenes Spiel bearbeiten",
"library_add_button": "Eigenes Spiel hinzufügen",
"library_field_title": "Titel",
@@ -209,6 +284,16 @@
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
"library_field_players": "Spieler",
"library_details_legend": "Details (optional)",
"library_owned_by": "über {provider}",
"library_providers_title": "Von Plugins synchronisiert",
"library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.",
"library_provider_count": "{count} Einträge",
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
"library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
"library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
"library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
"library_save": "Speichern",
"library_create": "Hinzufügen",
"library_cancel": "Abbrechen",
@@ -216,15 +301,10 @@
"library_delete": "Löschen",
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
"settings_title": "Einstellungen",
"settings_token_label": "API-Token",
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
"settings_language": "Sprache",
"settings_save": "Speichern",
"settings_saved": "Gespeichert.",
"common_loading": "Wird geladen…",
"common_error": "Etwas ist schiefgelaufen.",
"common_retry": "Erneut versuchen",
"common_yes": "Ja",
"common_cancel": "Abbrechen",
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
"login_title": "Anmelden",
@@ -247,6 +327,7 @@
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
"logs_download": "Logs herunterladen",
"logs_stalled": "Log-Abruf fehlgeschlagen — die zuletzt empfangenen Zeilen bleiben sichtbar.",
"logs_share": "Logs teilen",
"logs_copy": "Logs in die Zwischenablage kopieren",
"logs_copied": "Logs in die Zwischenablage kopiert",
@@ -265,6 +346,7 @@
"stats_kind_native": "Nativ",
"stats_kind_gamestream": "GameStream",
"stats_live_title": "Live",
"stats_live_window": "Es werden die letzten {count} Messpunkte gezeigt. Die gespeicherte Aufzeichnung enthält alles.",
"stats_live_waiting": "Scharf — warte auf die ersten Proben. Starte eine Sitzung, um aufzuzeichnen.",
"stats_latency_title": "Latenz nach Stufe",
"stats_latency_axis": "µs",
@@ -313,6 +395,7 @@
"store_from_source": "von",
"store_search_placeholder": "Plugins durchsuchen…",
"store_filter_all": "Alle Quellen",
"store_all_sources_failed": "Kein Katalog konnte geladen werden ({sources}). Der Store ist womöglich nicht leer — sieh im Tab „Quellen“ nach.",
"store_empty": "Noch keine Plugins im Katalog.",
"store_no_match": "Kein Plugin passt zur Suche.",
"store_by_author": "von {author}",
@@ -340,6 +423,7 @@
"store_installed_empty": "Noch keine Plugins installiert.",
"store_running": "Läuft",
"store_stopped": "Läuft nicht",
"store_version_unknown": "Version unbekannt",
"store_uninstall": "Deinstallieren",
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
@@ -369,6 +453,7 @@
"store_source_trust_title": "Dieser Quelle vertrauen?",
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
"store_source_password": "Konsolen-Passwort",
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
"store_install_title": "{title} installieren?",
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
@@ -387,12 +472,16 @@
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
"store_spec_confirm": "Ungeprüft installieren",
"store_spec_password": "Konsolen-Passwort",
"store_spec_password_help": "Für ungeprüften Code wird das Passwort erneut gebraucht — eine Browser-Sitzung allein reicht dafür nicht.",
"store_job_install": "{target} wird installiert",
"store_job_uninstall": "{target} wird entfernt",
"store_job_done_install": "Installiert.",
"store_job_done_uninstall": "Entfernt.",
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
"store_job_lost": "Dieser Vorgang ist nicht mehr auffindbar",
"store_job_lost_hint": "Der Host wurde währenddessen neu gestartet. Im Tab „Installiert“ siehst du, ob er durchgelaufen ist.",
"store_job_log": "Log anzeigen",
"store_job_dismiss": "Ausblenden",
"store_phase_queued": "In der Warteschlange",
@@ -410,6 +499,8 @@
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_closing_in": "Client ist weg wird in {time} geschlossen, falls er nicht zurückkommt",
"games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?",
"action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?",
"games_end_now": "Jetzt beenden",
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
@@ -467,6 +558,7 @@
"update_apply_wrong_password": "Falsches Passwort.",
"update_apply_throttled": "Zu viele Versuche — kurz warten und erneut versuchen.",
"update_apply_working": "Starte…",
"update_apply_give_up": "Nicht weiter warten",
"update_apply_timeout": "Der Host hat sich noch nicht zurückgemeldet. Möglicherweise installiert er noch — falls das anhält, den Dienst auf der Maschine und das Installer-Log im punktfunk-Datenverzeichnis prüfen (logs\\update-*.log).",
"update_applying_title": "Aktualisiere auf {version}",
"update_stage_downloading": "Lade den Installer… {pct}%",
+102 -10
View File
@@ -6,10 +6,51 @@
"nav_dashboard": "Dashboard",
"nav_host": "Host",
"nav_displays": "Virtual displays",
"nav_clients": "Paired clients",
"nav_pairing": "Pairing",
"nav_library": "Library",
"nav_automation": "Automation",
"automation_title": "Automation",
"automation_subtitle": "Run a command, or call a webhook, when something happens on this host.",
"automation_hooks_title": "Event hooks",
"automation_add": "Add hook",
"automation_empty": "No hooks yet. Add one to run something when a stream starts, a client pairs, or a game exits.",
"automation_edit": "Edit hook",
"automation_delete": "Delete hook",
"automation_delete_confirm": "Delete this hook?",
"automation_unsaved": "Unsaved changes",
"automation_saved": "Automation saved",
"automation_save_failed": "Could not save the automation.",
"automation_debounce_badge": "min {ms} ms apart",
"automation_hook_title": "Hook",
"automation_hook_help": "Pick the event, then what should happen. A trailing .* matches every event in that domain.",
"automation_hook_save": "Done",
"automation_field_on": "When",
"automation_field_on_help": "The event that fires this hook — the same names the host publishes on its event stream.",
"automation_field_action": "Then",
"automation_action_run": "Run a command",
"automation_action_webhook": "Call a webhook",
"automation_action_run_help": "Runs detached, as the host user, with the event JSON on stdin and PF_EVENT_* in the environment.",
"automation_action_webhook_help": "The event JSON is POSTed to this URL.",
"automation_field_hmac": "HMAC secret file (optional)",
"automation_field_hmac_help": "A private, operator-owned file holding the signing secret. The request carries X-Punktfunk-Signature so the receiver can verify it really came from this host.",
"automation_field_filter": "Only for a specific client or game",
"automation_filter_client": "Client name",
"automation_filter_app": "Game / app id",
"automation_field_debounce": "Minimum gap (ms)",
"automation_field_timeout": "Timeout (s)",
"automation_confirm_title": "Save automation?",
"automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.",
"library_delete_failed": "Could not delete this entry.",
"gpu_apply_failed": "Could not change the GPU preference.",
"stats_start_failed": "Could not start the capture.",
"stats_stop_failed": "Could not stop the capture — it may not have been saved.",
"stats_delete_failed": "Could not delete this recording.",
"stats_download_failed": "Could not download this recording.",
"games_end_failed": "Could not end the game.",
"action_stop_failed": "Could not stop the session.",
"action_idr_failed": "Could not request a keyframe.",
"nav_settings": "Settings",
"nav_close_menu": "Close menu",
"nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
@@ -25,14 +66,47 @@
"status_sessions_active": "{count} active",
"status_no_session": "No active session",
"status_paired_count": "Paired clients",
"status_pin_waiting": "Waiting",
"status_pin_none": "None",
"status_pin_pending": "Pairing PIN pending",
"stream_codec": "Codec",
"stream_resolution": "Resolution",
"stream_fps": "Frame rate",
"stream_first_frame": "First frame",
"stream_last_resize": "Last resize",
"stream_packet_size": "Packet size",
"stream_min_fec": "FEC floor",
"stream_bitrate": "Bitrate",
"activity_title": "Recent activity",
"activity_empty": "Nothing yet — events show up here as they happen on the host.",
"activity_client_connected": "Connected",
"activity_client_disconnected": "Disconnected",
"activity_session_started": "Session started",
"activity_session_ended": "Session ended",
"activity_stream_started": "Stream started",
"activity_stream_stopped": "Stream stopped",
"activity_game_running": "Game running",
"activity_game_exited": "Game exited",
"activity_pairing_pending": "Pairing requested",
"activity_pairing_completed": "Paired",
"activity_pairing_denied": "Pairing denied",
"activity_display_created": "Display created",
"activity_display_released": "Display released",
"activity_library_changed": "Library changed",
"activity_update_available": "Update available",
"activity_update_applied": "Update applied",
"activity_plugins_changed": "Plugins changed",
"activity_store_changed": "Store changed",
"activity_host_started": "Host started",
"activity_host_stopping": "Host stopping",
"action_stop_session": "Stop session",
"action_request_idr": "Request keyframe",
"action_unpair": "Unpair",
"connect_title": "Connect a device",
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
"connect_address": "Host address",
"connect_link": "Deep link",
"connect_copy": "Copy",
"host_identity": "Identity",
"host_hostname": "Hostname",
"host_os": "Operating system",
@@ -60,7 +134,8 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
"host_displays": "Virtual displays",
"host_conflicts_title": "Another game-streaming server is running on this machine",
"host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients — which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
"display_preset": "Preset",
@@ -90,6 +165,7 @@
"display_state_lingering": "Lingering",
"display_state_pinned": "Pinned",
"display_release_btn": "Release",
"display_refresh_failed": "Could not refresh from the host — showing the last known settings. Your edits are safe.",
"display_release_all": "Release all kept",
"display_expires_in": "tears down in {sec}s",
"display_sessions": "{count} streaming",
@@ -97,6 +173,7 @@
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
"display_arrange_save": "Save arrangement",
"display_custom_desc": "Set every option yourself.",
"display_preset_apply_named": "Apply preset {name}",
"display_preset_current": "Active",
"display_preset_soon": "coming soon",
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
@@ -144,11 +221,8 @@
"display_all_saved": "All changes saved",
"display_revert": "Discard changes",
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
"clients_fingerprint": "Fingerprint",
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
"pairing_title": "Pairing",
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
@@ -186,6 +260,7 @@
"library_store_steam": "Steam",
"library_store_custom": "Custom",
"library_add_title": "Add a custom game",
"library_edit_overwrites": "Saving replaces this entry with what's in this form. Prep/undo commands and detection hints set outside the console aren't shown here and will be cleared.",
"library_edit_title": "Edit custom game",
"library_add_button": "Add custom game",
"library_field_title": "Title",
@@ -209,6 +284,16 @@
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
"library_field_players": "Players",
"library_details_legend": "Details (optional)",
"library_owned_by": "via {provider}",
"library_providers_title": "Synced by plugins",
"library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.",
"library_provider_count": "{count} entries",
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",
"library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
"library_provider_purged": "Removed the entries synced by “{provider}”.",
"library_provider_purge_failed": "Could not remove this provider's entries.",
"library_save": "Save",
"library_create": "Add",
"library_cancel": "Cancel",
@@ -216,15 +301,10 @@
"library_delete": "Delete",
"library_delete_confirm": "Delete this custom game? This can't be undone.",
"settings_title": "Settings",
"settings_token_label": "API token",
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
"settings_language": "Language",
"settings_save": "Save",
"settings_saved": "Saved.",
"common_loading": "Loading…",
"common_error": "Something went wrong.",
"common_retry": "Retry",
"common_yes": "Yes",
"common_cancel": "Cancel",
"common_unauthorized": "Session expired — redirecting to sign in…",
"login_title": "Sign in",
@@ -247,6 +327,7 @@
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
"logs_dropped": "Some entries were evicted before they could be fetched",
"logs_download": "Download logs",
"logs_stalled": "Log polling failed — showing the last lines received.",
"logs_share": "Share logs",
"logs_copy": "Copy logs to clipboard",
"logs_copied": "Logs copied to clipboard",
@@ -265,6 +346,7 @@
"stats_kind_native": "Native",
"stats_kind_gamestream": "GameStream",
"stats_live_title": "Live",
"stats_live_window": "Showing the last {count} samples. The saved recording keeps everything.",
"stats_live_waiting": "Armed — waiting for the first samples. Start a session to begin recording.",
"stats_latency_title": "Latency by stage",
"stats_latency_axis": "µs",
@@ -313,6 +395,7 @@
"store_from_source": "from",
"store_search_placeholder": "Search plugins…",
"store_filter_all": "All sources",
"store_all_sources_failed": "Could not fetch any catalog ({sources}). The store may not be empty — check the Sources tab.",
"store_empty": "No plugins in the catalog yet.",
"store_no_match": "No plugin matches your search.",
"store_by_author": "by {author}",
@@ -340,6 +423,7 @@
"store_installed_empty": "No plugins installed yet.",
"store_running": "Running",
"store_stopped": "Not running",
"store_version_unknown": "version unknown",
"store_uninstall": "Uninstall",
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
@@ -369,6 +453,7 @@
"store_source_trust_title": "Trust this source?",
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
"store_source_password": "Console password",
"store_source_trust_confirm": "I understand — add the source",
"store_install_title": "Install {title}?",
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
@@ -387,12 +472,16 @@
"store_spec_confirm_field": "Type the package spec again to confirm",
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
"store_spec_confirm": "Install unverified",
"store_spec_password": "Console password",
"store_spec_password_help": "Running unreviewed code needs the password again — a browser session on its own can't do this.",
"store_job_install": "Installing {target}",
"store_job_uninstall": "Removing {target}",
"store_job_done_install": "Installed.",
"store_job_done_uninstall": "Removed.",
"store_job_failed": "The job failed.",
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
"store_job_lost": "Lost track of this job",
"store_job_lost_hint": "The host restarted while it ran. Check the Installed tab to see whether it finished.",
"store_job_log": "Show log",
"store_job_dismiss": "Dismiss",
"store_phase_queued": "Queued",
@@ -410,6 +499,8 @@
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
"games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?",
"action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?",
"games_end_now": "End now",
"session_game_title": "When a game or a session ends",
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
@@ -467,6 +558,7 @@
"update_apply_wrong_password": "Wrong password.",
"update_apply_throttled": "Too many attempts — wait a moment and try again.",
"update_apply_working": "Starting…",
"update_apply_give_up": "Stop waiting",
"update_apply_timeout": "The host hasn't come back yet. It may still be installing — if this persists, check the service on the machine and the installer log under the punktfunk data directory (logs\\update-*.log).",
"update_applying_title": "Updating to {version}",
"update_stage_downloading": "Downloading the installer… {pct}%",
+50 -3
View File
@@ -28,6 +28,18 @@ const ws = import.meta._websocket
? wsAdapter(nitroApp.h3App.websocket)
: undefined;
// The socket peer, handed to the app as a trusted header.
//
// Nitro's `localFetch` (below) hands the app a SYNTHETIC request whose socket has no
// `remoteAddress`, so h3's `getRequestIP()` returns undefined *inside* the app and every
// per-peer decision collapses onto one shared bucket. That silently defeated the login
// throttle: five wrong passwords from anywhere locked out everyone, including the operator
// (and, since the update-apply route shares that budget, locked out host updates too).
// `server.requestIP(req)` is the only place the real peer is knowable, so we stamp it here.
// Any inbound copy is deleted first, so a client cannot forge it.
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
const PEER_IP_HEADER = "x-pf-peer-ip";
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
@@ -36,11 +48,41 @@ const tls =
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
: undefined;
// Half-configured TLS is not a warning, it is a refusal.
//
// Two silent failures hide here, and both end with the operator staring at a console that looks
// fine. One path set and the other missing drops to plain HTTP — the login password then crosses
// the LAN in the clear on a server the operator believes is TLS. And PUNKTFUNK_UI_SECURE without
// TLS marks the session cookie Secure, which a browser refuses to store over http://, so login
// "succeeds" and every request after it is unauthenticated, forever.
//
// Neither state can serve a working console, so exiting is strictly better than serving a broken
// one: a supervisor logs the reason and the operator sees a stopped service instead of a subtly
// wrong one.
const secureFlag = /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "");
if (Boolean(certPath) !== Boolean(keyPath)) {
console.error(
`punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` +
"TLS needs BOTH. Refusing to start rather than serve the login password in the clear.",
);
process.exit(1);
}
if (!tls && secureFlag) {
console.error(
"punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " +
"cookie would be marked Secure and dropped by the browser over http://, so login could " +
"never stick. Refusing to start — set PUNKTFUNK_UI_TLS_CERT/_KEY, or unset PUNKTFUNK_UI_SECURE.",
);
process.exit(1);
}
const server = Bun.serve({
port: process.env.NITRO_PORT || process.env.PORT || 3000,
host: process.env.NITRO_HOST || process.env.HOST,
idleTimeout:
Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || undefined,
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
// reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable.
idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120,
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
tls,
websocket: import.meta._websocket ? ws.websocket : undefined,
@@ -53,10 +95,15 @@ const server = Bun.serve({
if (req.body) {
body = await req.arrayBuffer();
}
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
const headers = new Headers(req.headers);
headers.delete(PEER_IP_HEADER);
const peer = server.requestIP(req)?.address;
if (peer) headers.set(PEER_IP_HEADER, peer);
return nitroApp.localFetch(url.pathname + url.search, {
host: url.hostname,
protocol: url.protocol,
headers: req.headers,
headers,
method: req.method,
redirect: req.redirect,
body,
+1
View File
@@ -11,6 +11,7 @@
"dev": "vite dev --port 47992",
"prebuild": "orval --config orval.config.ts",
"build": "vite build",
"postbuild": "node tools/check-i18n.mjs",
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Punktfunk",
"short_name": "Punktfunk",
"description": "Management console for a punktfunk streaming host.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0f",
"theme_color": "#6c5bf3",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any"
}
]
}
+23 -1
View File
@@ -7,6 +7,7 @@ import {
getRequestHeader,
getRequestURL,
sendRedirect,
setResponseHeader,
setResponseStatus,
useSession,
} from "h3";
@@ -14,12 +15,30 @@ import {
isPublicPath,
type SessionData,
sessionConfig,
sessionEpoch,
uiPassword,
} from "../util/auth";
export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event);
// Baseline response headers for everything this server emits. Deliberately modest: a plugin's
// own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
// worth having would break third-party plugin pages we don't control. What is safe to assert
// unconditionally still closes the cheap holes:
// nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
// frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin)
// object-src — no Flash/applet embedding anywhere
// base-uri — a stray <base> can't repoint every relative URL on the page
// Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
setResponseHeader(event, "Referrer-Policy", "no-referrer");
setResponseHeader(
event,
"Content-Security-Policy",
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
);
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
// added with the update-apply route where CSRF ≈ code execution — design
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
@@ -46,7 +65,10 @@ export default defineEventHandler(async (event) => {
}
const session = await useSession<SessionData>(event, sessionConfig());
if (session.data.authenticated) return; // authenticated — let it through
// The epoch check is what makes logout mean something: a cookie sealed before the last
// revocation unseals fine but no longer matches, so it is refused like any other bad session.
if (session.data.authenticated && session.data.epoch === sessionEpoch())
return; // authenticated — let it through
if (pathname.startsWith("/api")) {
setResponseStatus(event, 401);
+6 -5
View File
@@ -5,14 +5,15 @@
import {
createError,
defineEventHandler,
getRequestIP,
readBody,
setResponseHeader,
useSession,
} from "h3";
import {
peerAddress,
type SessionData,
sessionConfig,
sessionEpoch,
timingSafeEqual,
uiPassword,
} from "../../util/auth";
@@ -31,9 +32,9 @@ export default defineEventHandler(async (event) => {
});
}
// The socket peer address — deliberately NOT trusting X-Forwarded-For (spoofable unless we sit
// behind a known proxy, which the packaged console does not). Falls back to a single shared bucket
// if the address is somehow unavailable, so the throttle still applies.
const ip = getRequestIP(event) ?? "unknown";
// behind a known proxy, which the packaged console does not). See `peerAddress`: under the Bun
// entry this is the real peer; the shared "unknown" bucket is only a last-resort fallback.
const ip = peerAddress(event);
// Throttle BEFORE touching the password so a locked-out client can't keep the guess loop spinning.
const wait = throttleRetryAfterMs(ip);
@@ -53,6 +54,6 @@ export default defineEventHandler(async (event) => {
}
recordLoginSuccess(ip);
const session = await useSession<SessionData>(event, sessionConfig());
await session.update({ authenticated: true });
await session.update({ authenticated: true, epoch: sessionEpoch() });
return { ok: true };
});
+12 -2
View File
@@ -1,9 +1,19 @@
// POST /_auth/logout — clear the session cookie.
// POST /_auth/logout — clear the session cookie AND revoke every session issued so far.
//
// Clearing alone only deletes the browser's copy: the cookie is stateless, so a captured value
// stayed valid for its whole 7-day TTL and "log out" logged nothing out. Bumping the epoch means
// the gate rejects every cookie sealed before now. Single-user console, so "log out" and "sign out
// everywhere" are the same action — which is the safer of the two to make the default.
import { defineEventHandler, useSession } from "h3";
import { type SessionData, sessionConfig } from "../../util/auth";
import {
revokeAllSessions,
type SessionData,
sessionConfig,
} from "../../util/auth";
export default defineEventHandler(async (event) => {
const session = await useSession<SessionData>(event, sessionConfig());
await session.clear();
revokeAllSessions();
return { ok: true };
});
+12 -2
View File
@@ -10,7 +10,12 @@ import {
proxyRequest,
setResponseStatus,
} from "h3";
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
import {
isLoopbackUrl,
mgmtToken,
mgmtUrl,
normalizePath,
} from "../../util/auth";
export default defineEventHandler((event) => {
const { pathname, search } = getRequestURL(event);
@@ -18,7 +23,12 @@ export default defineEventHandler((event) => {
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
//
// Matched against the NORMALIZED path as well as the raw one: `/api//v1/...`, `/api/./v1/...`
// and percent-encoded variants all reach the same upstream route, and a denylist that only
// knows the canonical spelling is one router-quirk away from leaking the secret.
const denied = /^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/i;
if (denied.test(pathname) || denied.test(normalizePath(pathname))) {
setResponseStatus(event, 403);
return {
error: "plugin UI credentials are not accessible from the browser",
+88
View File
@@ -0,0 +1,88 @@
// GET /api/v1/events — the host's SSE lifecycle stream, proxied with the body left STREAMING.
//
// Why this route exists at all, when the `/api/**` catch-all already proxies everything:
// the generic path cannot stream. h3's `proxyRequest` pumps the upstream body into the node-style
// response with `res.write`, and under the deployed Bun entry that response is a `node-mock-http`
// object whose writes are accumulated and only turned into a real Response when the handler
// returns. Measured: three frames sent one second apart arrive at the browser together, ~3 s late,
// when the upstream closes. For an SSE stream that is fatal — it never closes, so nothing ever
// arrives, and every event-driven update in the console would silently never fire.
//
// Returning a WEB `Response` whose body is the upstream's own `ReadableStream` sidesteps the
// node-response emulation entirely: h3 hands it back as-is and the Bun entry passes it through.
//
// Everything else matches the catch-all: session-gated by middleware/auth.ts, mgmt bearer injected
// server-side, TLS relaxed only for the loopback hop, 401 → 502.
import {
createError,
defineEventHandler,
getRequestHeader,
getRequestURL,
} from "h3";
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../../util/auth";
export default defineEventHandler(async (event) => {
const token = mgmtToken();
if (!token) {
throw createError({
statusCode: 503,
statusMessage: "management token not configured",
});
}
const base = mgmtUrl();
const { search } = getRequestURL(event);
const headers: Record<string, string> = {
authorization: `Bearer ${token}`,
accept: "text/event-stream",
// Ask for no compression: a buffering encoder defeats the point of a live stream.
"accept-encoding": "identity",
};
// Forward the SSE resume cursor so a reconnect replays from the host's ring rather than
// silently skipping whatever happened while we were away.
const lastId = getRequestHeader(event, "last-event-id");
if (lastId) headers["last-event-id"] = lastId;
const init: RequestInit = { method: "GET", headers, redirect: "manual" };
if (isLoopbackUrl(base)) {
// Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts).
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
rejectUnauthorized: false,
};
}
let upstream: Response;
try {
upstream = await fetch(`${base}/api/v1/events${search}`, init);
} catch (cause) {
throw createError({
statusCode: 502,
statusMessage: "management API unreachable",
cause,
});
}
if (upstream.status === 401) {
throw createError({
statusCode: 502,
statusMessage:
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
});
}
if (!upstream.ok || !upstream.body) {
throw createError({
statusCode: 502,
statusMessage: `management API refused the event stream (${upstream.status})`,
});
}
// The upstream body, untouched. `no-transform` + `X-Accel-Buffering: no` tell any intermediary
// (and Nitro's own compression) to keep their hands off a live stream.
return new Response(upstream.body, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
},
});
});
+24
View File
@@ -0,0 +1,24 @@
// PUT /api/v1/hooks — writing a hook means writing a SHELL COMMAND the host will execute on its own
// events, as the host user. That is code execution by any other name, so it joins update/apply and
// raw-spec installs behind the console password (util/confirm.ts): a 7-day session cookie must not
// be enough to leave a persistent command behind on the machine.
//
// Wins over the `/api/**` catch-all by h3 route specificity. GET is not gated — reading the current
// automation is ordinary console business.
import { defineEventHandler, readBody } from "h3";
import { confirmPassword } from "../../../util/confirm";
import { forwardJson } from "../../../util/forward";
interface HooksBody {
hooks?: unknown[];
password?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<HooksBody>(event);
confirmPassword(event, body?.password);
// Rebuild from the one field the host takes, so the password cannot leak upstream.
return forwardJson(event, "/api/v1/hooks", "PUT", {
hooks: Array.isArray(body?.hooks) ? body.hooks : [],
});
});
@@ -0,0 +1,32 @@
// POST /api/v1/store/install — wins over the `/api/**` catch-all (h3 route specificity), so the
// raw-spec branch can never reach the host without a password.
//
// Two shapes arrive here:
// { source, id } — a curated catalog entry. Forwarded as-is: the operator
// already made the trust decision when they added the source.
// { spec, accept_unverified: true } — an unreviewed package, no catalog, no pinning. This is
// arbitrary code execution on the host, so it is gated on the
// console password exactly like update/apply (util/confirm.ts).
import { defineEventHandler, readBody } from "h3";
import { confirmPassword } from "../../../../util/confirm";
import { forwardJson } from "../../../../util/forward";
interface InstallBody {
source?: string;
id?: string;
spec?: string;
accept_unverified?: boolean;
password?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<InstallBody>(event);
const rawSpec = body?.accept_unverified === true;
if (rawSpec) confirmPassword(event, body?.password);
// The password stops here — rebuild the upstream body from known fields so it cannot leak
// through, and so an unexpected extra field can't ride along to the host.
const upstream = rawSpec
? { spec: String(body?.spec ?? ""), accept_unverified: true }
: { source: String(body?.source ?? ""), id: String(body?.id ?? "") };
return forwardJson(event, "/api/v1/store/install", "POST", upstream);
});
@@ -0,0 +1,33 @@
// PUT /api/v1/store/sources/{name} — adding or repointing a catalog source is a TRUST-ROOT change:
// every future install from that source is admitted on its say-so, and `public_key` is optional, so
// a source may be unsigned. That is the boundary worth a password (util/confirm.ts), not each
// individual install past it. Wins over the `/api/**` catch-all by h3 route specificity.
//
// DELETE is deliberately NOT gated — removing a source only ever narrows what the host will trust.
import { defineEventHandler, getRouterParam, readBody } from "h3";
import { confirmPassword } from "../../../../../util/confirm";
import { forwardJson } from "../../../../../util/forward";
interface SourceBody {
url?: string;
public_key?: string;
password?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<SourceBody>(event);
confirmPassword(event, body?.password);
const name = getRouterParam(event, "name") ?? "";
// Rebuild the body from known fields so the password cannot leak upstream.
const upstream: { url: string; public_key?: string } = {
url: String(body?.url ?? ""),
};
const key = body?.public_key?.trim();
if (key) upstream.public_key = key;
return forwardJson(
event,
`/api/v1/store/sources/${encodeURIComponent(name)}`,
"PUT",
upstream,
);
});
+14 -82
View File
@@ -1,89 +1,21 @@
// POST /api/v1/update/apply — the ONE proxied route with an extra gate: the console password
// must be re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session
// cookie alone must not be able to update-and-restart the host; the password is verified HERE
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login
// throttle's per-IP budget, so apply can't be used as a password oracle.
// POST /api/v1/update/apply — a proxied route with an extra gate: the console password must be
// re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session cookie alone
// must not be able to update-and-restart the host; the password is verified in `confirmPassword`
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login throttle's
// per-peer budget, so apply can't be used as a password oracle.
//
// This specific file wins over the `[...]` catch-all (h3 route specificity) — verified in the
// U1 gate; everything else about proxying (bearer injection, loopback TLS scoping, 401→502)
// mirrors ../../[...].ts.
import {
createError,
defineEventHandler,
getRequestIP,
readBody,
setResponseHeader,
setResponseStatus,
} from "h3";
import {
isLoopbackUrl,
mgmtToken,
mgmtUrl,
timingSafeEqual,
uiPassword,
} from "../../../../util/auth";
import {
recordLoginFailure,
recordLoginSuccess,
throttleRetryAfterMs,
} from "../../../../util/loginThrottle";
// lives in util/forward.ts and mirrors ../../[...].ts.
import { defineEventHandler, readBody } from "h3";
import { confirmPassword } from "../../../../util/confirm";
import { forwardJson } from "../../../../util/forward";
export default defineEventHandler(async (event) => {
const expected = uiPassword();
if (!expected) {
throw createError({ statusCode: 503, statusMessage: "auth not configured" });
}
const ip = getRequestIP(event) ?? "unknown";
const wait = throttleRetryAfterMs(ip);
if (wait > 0) {
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
throw createError({
statusCode: 429,
statusMessage: "too many attempts — try again shortly",
});
}
const body = await readBody<{ password?: string; force?: boolean }>(event);
const password = String(body?.password ?? "");
if (!timingSafeEqual(password, expected)) {
recordLoginFailure(ip);
throw createError({
statusCode: 401,
statusMessage: "password confirmation failed",
});
}
recordLoginSuccess(ip);
const token = mgmtToken();
if (!token) {
setResponseStatus(event, 503);
return { error: "management token not configured" };
}
const base = mgmtUrl();
const init: RequestInit = {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
// The password stops here — the host only ever sees the force flag.
body: JSON.stringify({ force: body?.force === true }),
};
if (isLoopbackUrl(base)) {
// Bun.fetch extension (see ../../[...].ts for why this is scoped per-request).
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
rejectUnauthorized: false,
};
}
const upstream = await fetch(`${base}/api/v1/update/apply`, init);
if (upstream.status === 401) {
throw createError({
statusCode: 502,
statusMessage:
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
});
}
setResponseStatus(event, upstream.status);
setResponseHeader(event, "content-type", "application/json");
return upstream.text();
confirmPassword(event, body?.password);
// The password stops here — the host only ever sees the force flag.
return forwardJson(event, "/api/v1/update/apply", "POST", {
force: body?.force === true,
});
});
+56 -5
View File
@@ -39,10 +39,12 @@ export default defineEventHandler(async (event) => {
delete headers.authorization;
headers["x-forwarded-prefix"] = prefix;
const method = event.method;
const body =
method === "GET" || method === "HEAD"
? undefined
: ((await readRawBody(event, false)) as Uint8Array | undefined);
// Only read a body for the methods that can carry one. `readRawBody` asserts a payload method,
// so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw
// 405 out of the CONSOLE before the plugin was ever dialed.
const body = BODY_METHODS.has(method)
? ((await readRawBody(event, false)) as Uint8Array | undefined)
: undefined;
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
const attempt = async (bustCache: boolean): Promise<Response | null> => {
@@ -74,5 +76,54 @@ export default defineEventHandler(async (event) => {
setResponseStatus(event, 502);
return { error: `plugin "${id}" is not running` };
}
return sendWebResponse(event, resp);
return sendWebResponse(event, sanitize(resp));
});
/** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be
* handed to `readRawBody`. */
const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Rebuild a plugin's response before it goes out on the console's own origin.
*
* An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it
* returns is asserted for the console itself and the first version of this dropped four names it
* had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the
* browser would honour it for this origin and wipe `pf_session`, signing the operator out of the
* console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`,
* `X-Frame-Options` or `Access-Control-Allow-Origin` all of which would speak for us.
*
* So: name what a plugin page legitimately needs, and drop the rest. Framing headers
* (content-encoding/length, transfer-encoding) are deliberately absent `fetch` already decoded
* the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are
* recomputed.
*/
const PLUGIN_HEADER_ALLOWLIST = new Set([
"content-type",
"cache-control",
"etag",
"last-modified",
"expires",
"vary",
"content-language",
"content-disposition",
"accept-ranges",
"content-range",
"location", // its own redirects, within its own prefix
"link", // preload hints for its own assets
"x-forwarded-prefix",
]);
function sanitize(resp: Response): Response {
const headers = new Headers();
for (const [k, v] of resp.headers) {
if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v);
}
// 204/304 must not carry a body — passing one through throws in the Response constructor.
const bodyless = resp.status === 204 || resp.status === 304;
return new Response(bodyless ? null : resp.body, {
status: resp.status,
statusText: resp.statusText,
headers,
});
}
+120 -3
View File
@@ -1,3 +1,55 @@
/**
* A revocation marker for issued sessions, PERSISTED across restarts.
*
* The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only
* deletes the BROWSER's copy. A cookie captured beforehand stayed valid for its full 7-day TTL
* "log out" did not log anything out.
*
* The counter has to survive a restart or it does not do its job: an in-memory `let epoch = 1`
* revokes within one process run, then resets to 1 the next time the service starts, and a cookie
* captured from that first run is accepted again for the rest of its TTL. (The seal key cannot save
* us it is derived from the stable mgmt token, so pre-restart cookies still unseal fine.) So it
* lives in a file next to the host's own config.
*
* Best-effort by design: if the file cannot be read or written the console still works, it just
* falls back to in-memory revocation for this process. Refusing to log anyone out because a state
* file is unwritable would be the wrong trade for a LAN console.
*/
const EPOCH_FILE = (): string =>
process.env.PUNKTFUNK_UI_EPOCH_FILE ??
join(
process.env.PUNKTFUNK_CONFIG_DIR ?? join(homedir(), ".config", "punktfunk"),
"web-session-epoch",
);
let epochCache: number | null = null;
/** The epoch a new session is stamped with, and the one the gate requires. */
export function sessionEpoch(): number {
if (epochCache !== null) return epochCache;
try {
const raw = readFileSync(EPOCH_FILE(), "utf8").trim();
const n = Number.parseInt(raw, 10);
epochCache = Number.isFinite(n) && n > 0 ? n : 1;
} catch {
epochCache = 1; // no file yet — first run
}
return epochCache;
}
/** Invalidate every session issued so far (what logging out does). */
export function revokeAllSessions(): void {
const next = sessionEpoch() + 1;
epochCache = next;
try {
mkdirSync(dirname(EPOCH_FILE()), { recursive: true });
writeFileSync(EPOCH_FILE(), String(next), { mode: 0o600 });
} catch {
// Unwritable state dir: the bump still holds for this process, which is the common case
// (log out, walk away). It is weaker than persisted, and better than refusing to log out.
}
}
// Shared auth helpers for the Nitro server (the deployed Bun server). Single-user,
// shared-password gate: the user logs in with PUNKTFUNK_UI_PASSWORD, which sets a SEALED
// (h3 useSession — AES-GCM) cookie; every request is gated by server/middleware/auth.ts.
@@ -8,18 +60,50 @@ import {
createHash,
timingSafeEqual as nodeTimingSafeEqual,
} from "node:crypto";
import type { SessionConfig } from "h3";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import {
getRequestHeader,
getRequestIP,
type H3Event,
type SessionConfig,
} from "h3";
export const SESSION_NAME = "pf_session";
/** Set by the Bun entry (nitro-entry/bun-https.mjs) to the real socket peer, after deleting any
* inbound copy. Keep the name in sync with that file. */
const PEER_IP_HEADER = "x-pf-peer-ip";
/**
* The requesting peer, as the key for every per-peer budget (currently the login throttle).
*
* `getRequestIP()` alone does NOT work under the deployed server: Nitro's `localFetch` builds a
* synthetic request whose socket carries no `remoteAddress`, so h3 finds nothing and every caller
* collapses onto one shared bucket which turned the "per-IP" login throttle into a lockout any
* LAN peer could trigger for everyone. The Bun entry stamps the real peer into PEER_IP_HEADER
* (unforgeable: it deletes any client-supplied copy first), so prefer that.
*
* `getRequestIP` is kept as the fallback for any other ingress (a plain `node`/dev run), and
* "unknown" as the last resort a SHARED bucket, deliberately: an unattributable request must
* still be rate-limited, and failing open would make brute force unbounded.
*/
export function peerAddress(event: H3Event): string {
const stamped = getRequestHeader(event, PEER_IP_HEADER)?.trim();
if (stamped) return stamped;
return getRequestIP(event) ?? "unknown";
}
/** The login password. Empty string ⇒ auth is MISCONFIGURED (the gate fails closed). */
export function uiPassword(): string {
return process.env.PUNKTFUNK_UI_PASSWORD ?? "";
}
/** The management API the proxy forwards to (loopback by default never LAN-exposed). It serves
* HTTPS with the host's self-signed identity cert, so the deployment also sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for the (loopback-only) proxy fetch see .env.example. */
* HTTPS with the host's self-signed identity cert, so the proxy relaxes verification for that ONE
* loopback hop via Bun's per-request `tls` option (routes/api/[...].ts, util/forward.ts). There is
* deliberately no process-wide NODE_TLS_REJECT_UNAUTHORIZED see .env.example. */
export function mgmtUrl(): string {
return process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
}
@@ -118,9 +202,40 @@ export function isPublicPath(pathname: string): boolean {
if (pathname.startsWith("/_auth/")) return true;
if (pathname.startsWith("/assets/")) return true;
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
// The web manifest must be fetchable to install the app, and it says nothing a logged-out
// visitor cannot already see from the login page (name, colours, the brand mark).
if (pathname === "/manifest.webmanifest") return true;
return false;
}
/**
* Collapse a request path to the shape an upstream router will actually see: percent-decoded,
* with empty (`//`) and `.` segments dropped and `..` resolved. Used to test denylists against
* something an attacker cannot re-spell `/api//v1/x`, `/api/./v1/x` and `/api/v1/%78` all reach
* the same handler, so matching only the literal path is not a security boundary.
*
* Decoding is per segment and failure-tolerant: a malformed escape keeps the raw segment rather
* than throwing, so a bad path degrades to "does not match the canonical form" instead of a 500.
*/
export function normalizePath(pathname: string): string {
const out: string[] = [];
for (const raw of pathname.split("/")) {
let seg = raw;
try {
seg = decodeURIComponent(raw);
} catch {
// Malformed escape — keep the raw segment.
}
if (seg === "" || seg === ".") continue;
if (seg === "..") {
out.pop();
continue;
}
out.push(seg);
}
return `/${out.join("/")}`;
}
/** Validate a post-login redirect target: a same-origin path only. Resolves `next` against a
* sentinel origin and keeps it only if it stays same-origin rejecting absolute (`https://evil.com`),
* protocol-relative (`//evil.com`) AND backslash/tab variants (`/\evil.com`, which the WHATWG URL
@@ -138,4 +253,6 @@ export function safeNextPath(next: string | undefined): string {
export interface SessionData {
authenticated?: boolean;
/** The epoch this session was sealed under — see `sessionEpoch`. */
epoch?: number;
}
+55
View File
@@ -0,0 +1,55 @@
// Password re-confirmation for the routes where an authenticated session is NOT enough.
//
// The console's session cookie lives for 7 days, so on its own it must not be able to run new code
// on the host. Three routes clear that bar and each re-verifies the console password HERE (only the
// BFF knows it), strips it, and never forwards it:
//
// - POST /api/v1/update/apply — update-and-restart the host
// - POST /api/v1/store/install — but only for a RAW SPEC (`accept_unverified`), which
// runs an unreviewed package
// - PUT /api/v1/store/sources/{name} — adds a catalog SOURCE, i.e. a new trust root
//
// A catalog install from an already-trusted source is deliberately NOT gated: the operator made
// that trust decision when they added the source, and re-prompting on every install would train
// them to type the password without reading. The gate belongs at the trust boundary, not past it.
//
// Wrong attempts share the login throttle's per-peer budget, so none of these can be used as a
// password oracle, and a lockout covers all of them at once.
import { createError, type H3Event, setResponseHeader } from "h3";
import { peerAddress, timingSafeEqual, uiPassword } from "./auth";
import {
recordLoginFailure,
recordLoginSuccess,
throttleRetryAfterMs,
} from "./loginThrottle";
/**
* Verify the re-entered console password, or throw the right HTTP error (503 unconfigured,
* 429 throttled, 401 wrong). Returns nothing on success the caller proceeds.
*/
export function confirmPassword(event: H3Event, password: unknown): void {
const expected = uiPassword();
if (!expected) {
throw createError({
statusCode: 503,
statusMessage: "auth not configured",
});
}
const ip = peerAddress(event);
const wait = throttleRetryAfterMs(ip);
if (wait > 0) {
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
throw createError({
statusCode: 429,
statusMessage: "too many attempts — try again shortly",
});
}
if (!timingSafeEqual(String(password ?? ""), expected)) {
recordLoginFailure(ip);
throw createError({
statusCode: 401,
statusMessage: "password confirmation failed",
});
}
recordLoginSuccess(ip);
}
+65
View File
@@ -0,0 +1,65 @@
// One-shot forward to the management API, for the handful of routes that need their own handler
// (a password gate, a rewritten body) instead of the generic `/api/**` passthrough in
// routes/api/[...].ts. Everything about how we talk upstream is identical to the passthrough:
// server-side bearer injection, loopback-scoped TLS relaxation, and 401 → 502 so a host-token
// misconfiguration can't bounce a logged-in user into a redirect loop.
import {
createError,
type H3Event,
setResponseHeader,
setResponseStatus,
} from "h3";
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
/** Forward a JSON body to `path` on the management API and relay the upstream response verbatim. */
export async function forwardJson(
event: H3Event,
path: string,
method: string,
body: unknown,
): Promise<string> {
const token = mgmtToken();
if (!token) {
setResponseStatus(event, 503);
setResponseHeader(event, "content-type", "application/json");
return JSON.stringify({ error: "management token not configured" });
}
const base = mgmtUrl();
const init: RequestInit = {
method,
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
};
if (isLoopbackUrl(base)) {
// Bun.fetch extension — scoped per request, never process-wide (see routes/api/[...].ts).
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
rejectUnauthorized: false,
};
}
// A dead/unstarted host makes `fetch` reject. The generic passthrough answers 502 for that, so
// these routes must too — an unreachable upstream is not a console bug, and letting the
// rejection escape would surface it as a bare 500 "Server Error".
let upstream: Response;
try {
upstream = await fetch(`${base}${path}`, init);
} catch (cause) {
throw createError({
statusCode: 502,
statusMessage: "management API unreachable",
cause,
});
}
if (upstream.status === 401) {
throw createError({
statusCode: 502,
statusMessage:
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
});
}
setResponseStatus(event, upstream.status);
setResponseHeader(event, "content-type", "application/json");
return upstream.text();
}
+269
View File
@@ -0,0 +1,269 @@
// The host's event stream, wired to React Query's cache.
//
// The host publishes every lifecycle transition on `GET /api/v1/events` as SSE — client
// connect/disconnect, session and stream start/end, pairing decisions, display create/release,
// library/store/plugin changes, update availability, host start/stop. Nothing consumed it: the
// console learned about all of it by asking again on ten separate timers, so a change was up to
// 5 s stale, two pages could disagree with each other while you looked at them, and the Library
// page — which polls not at all — never noticed a newly installed game until a full reload.
//
// This subscribes once for the whole app and invalidates exactly the queries an event affects.
// It does NOT carry data into the cache: the REST snapshots stay the source of truth, and an event
// only says "this is stale now". That keeps the wire format additive-only (a kind we don't know
// costs us nothing) and means a missed event degrades to the polling behaviour we already had.
//
// Transport notes:
// - Same-origin, so the sealed session cookie rides along and the BFF injects the mgmt bearer;
// no auth work here. `EventSource` reconnects on its own and replays with `Last-Event-ID`,
// which h3's proxy forwards, so a dropped connection resumes from the host's ring.
// - The host sends a keep-alive comment every 15 s; the Bun entry's idle timeout is set above
// that (nitro-entry/bun-https.mjs) so we don't sever our own stream.
// - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything.
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { useEffect, useSyncExternalStore } from "react";
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
import { getGetDisplayStateQueryKey } from "@/api/gen/display/display";
import { getGetStatusQueryKey } from "@/api/gen/host/host";
import { getGetLibraryQueryKey } from "@/api/gen/library/library";
import { getListNativeClientsQueryKey } from "@/api/gen/native/native";
import { getGetPairingStatusQueryKey } from "@/api/gen/pairing/pairing";
import { getGetUpdateStatusQueryKey } from "@/api/gen/update/update";
import { boostPluginPolling, PLUGINS_KEY } from "@/api/plugins";
import { storeKeys } from "@/api/store";
/** Which query keys a given event kind invalidates. Unknown kinds are ignored on purpose.
* (The generated key helpers return `readonly` tuples, which is what React Query wants.) */
function keysFor(kind: string): readonly (readonly unknown[])[] {
const status = [getGetStatusQueryKey()];
switch (kind) {
// Anything that changes what the host is doing right now moves the dashboard's status.
case "client.connected":
case "client.disconnected":
case "session.started":
case "session.ended":
case "stream.started":
case "stream.stopped":
case "game.running":
case "game.exited":
return status;
// A display appearing or going away changes the live list, and its policy card shows
// "in effect" values derived from the same state.
case "display.created":
case "display.released":
return [...status, getGetDisplayStateQueryKey()];
case "pairing.pending":
case "pairing.denied":
return [...status, getGetPairingStatusQueryKey()];
// A completed pairing also adds a device to whichever plane's list is on screen.
case "pairing.completed":
return [
...status,
getGetPairingStatusQueryKey(),
getListPairedClientsQueryKey(),
getListNativeClientsQueryKey(),
];
// The base key with no params is a PREFIX of every parameterised library query, and React
// Query invalidates by prefix — so this catches the Dashboard's and the Library page's alike.
case "library.changed":
return [getGetLibraryQueryKey()];
case "update.available":
case "update.applied":
return [getGetUpdateStatusQueryKey()];
// A plugin install/uninstall moves the nav, the catalog, and the installed list.
case "plugins.changed":
case "store.changed":
return [
PLUGINS_KEY,
storeKeys.catalog,
storeKeys.installed,
storeKeys.runtime,
];
// The host came back: everything we hold predates it.
case "host.started":
return [];
default:
return [];
}
}
/**
* Mark one key's data wrong and refetch it.
*
* `refetchType: "all"` rather than the default `"active"`: an event says the HOST changed, so every
* cached copy is wrong, whether or not a mounted component happens to be observing it right now.
* The default only refetches queries with a live observer, which silently did nothing for a page
* that had just been re-rendered the cache stayed marked-stale-but-unfetched and the screen kept
* showing the old answer.
*/
function invalidate(qc: QueryClient, queryKey: readonly unknown[]): void {
qc.invalidateQueries({ queryKey, refetchType: "all" });
}
/** Invalidate every query — used on `dropped` (we fell off the ring) and on `host.started`. */
function resyncAll(qc: QueryClient): void {
qc.invalidateQueries({ refetchType: "all" });
}
// ---------------------------------------------------------------------------------------------
// The activity log.
//
// The same frames that drive invalidation are also, in themselves, the answer to "what has this
// host been doing?" — a question the console could not answer at all. Nothing else records this:
// the REST snapshots describe the present, and the host's own log is a developer artifact, not a
// narrative. So keep a small in-memory ring alongside the cache work.
//
// Deliberately NOT persisted and deliberately bounded: it is a live tail for someone watching, not
// an audit trail, and a page load starts fresh from whatever the ring replays.
// ---------------------------------------------------------------------------------------------
/** One thing that happened, as the feed renders it. */
export interface ActivityEntry {
/** The host's monotonic sequence number — stable, and a good React key. */
seq: number;
/** Unix ms, from the host's clock (never the browser's). */
ts_ms: number;
kind: string;
/** The event payload, shape depending on `kind` (see the EventKind schema). */
data: Record<string, unknown>;
}
const ACTIVITY_MAX = 200;
let activity: ActivityEntry[] = [];
const activityListeners = new Set<() => void>();
function pushActivity(entry: ActivityEntry): void {
// Guard against a replayed frame after a reconnect (`Last-Event-ID` can re-deliver the cursor).
if (activity.some((e) => e.seq === entry.seq)) return;
activity = [entry, ...activity].slice(0, ACTIVITY_MAX);
for (const l of activityListeners) l();
}
/** The activity tail, newest first. Re-renders as frames arrive. */
export function useActivity(): ActivityEntry[] {
return useSyncExternalStore(
(cb) => {
activityListeners.add(cb);
return () => activityListeners.delete(cb);
},
() => activity,
// The server has no stream, so SSR renders an empty feed and hydrates into the live one.
() => EMPTY_ACTIVITY,
);
}
const EMPTY_ACTIVITY: ActivityEntry[] = [];
/** Every kind we act on. A kind the host adds later simply has no listener — never a mis-handle. */
const KINDS = [
"client.connected",
"client.disconnected",
"session.started",
"session.ended",
"stream.started",
"stream.stopped",
"game.running",
"game.exited",
"pairing.pending",
"pairing.completed",
"pairing.denied",
"display.created",
"display.released",
"library.changed",
"update.available",
"update.applied",
"plugins.changed",
"store.changed",
"host.started",
] as const;
// ---------------------------------------------------------------------------------------------
// The connection is a module-level singleton, refcounted, NOT a per-component resource.
//
// It has to be. The subscription is app-lifetime, but the component that asks for it is not:
// during hydration TanStack Start mounts the app shell and discards it again ~15 ms later
// (measured), which ran an effect cleanup with no matching re-mount. Tied to that effect, the
// stream opened, closed, and never came back — the console looked subscribed and received nothing.
//
// So: `open()` hands out a reference and only the LAST release closes the socket, after a short
// grace period, so a remount inside that window re-attaches to the live stream instead of
// reconnecting. `EventSource` handles reconnection itself and replays with `Last-Event-ID`, which
// the SSE route forwards.
// ---------------------------------------------------------------------------------------------
let source: EventSource | null = null;
let refs = 0;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
/** The client to invalidate against — one per page load, re-pointed if React hands us a new one. */
let client: QueryClient | null = null;
/** How long the stream survives with no subscribers, so a hydration blip doesn't reconnect. */
const CLOSE_GRACE_MS = 10_000;
function attach(): void {
if (source) return;
source = new EventSource("/api/v1/events");
for (const kind of KINDS) {
source.addEventListener(kind, (ev) => {
// Record it first: the feed should show an event even for a kind we invalidate nothing for.
recordActivity(kind, ev);
if (!client) return;
// The installed set changed — but the runner is probably still restarting, so keep
// checking for a while rather than trusting this one refetch (see boostPluginPolling).
if (kind === "plugins.changed" || kind === "store.changed")
boostPluginPolling();
for (const key of keysFor(kind)) invalidate(client, key);
// `host.started` names no keys — the host is NEW, so everything we hold predates it.
if (kind === "host.started") resyncAll(client);
});
}
// We fell off the host's ring — every snapshot we hold may be wrong.
source.addEventListener("dropped", () => {
if (client) resyncAll(client);
});
}
/** Parse one SSE frame into the activity ring. A malformed frame is dropped, never thrown. */
function recordActivity(kind: string, ev: Event): void {
const raw = (ev as MessageEvent<string>).data;
if (typeof raw !== "string") return;
try {
const data = JSON.parse(raw) as Record<string, unknown>;
const seq = typeof data.seq === "number" ? data.seq : Number.NaN;
const ts = typeof data.ts_ms === "number" ? data.ts_ms : Number.NaN;
if (!Number.isFinite(seq) || !Number.isFinite(ts)) return;
pushActivity({ seq, ts_ms: ts, kind, data });
} catch {
// A frame we cannot parse is not worth breaking the stream over.
}
}
function release(): void {
refs -= 1;
if (refs > 0) return;
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
closeTimer = null;
if (refs > 0) return; // someone re-subscribed inside the grace window
source?.close();
source = null;
}, CLOSE_GRACE_MS);
}
/**
* Subscribe to the host's event stream. Safe to call from more than one component and safe on the
* server (`EventSource` is browser-only, so this is a no-op during SSR).
*/
export function useHostEvents(): void {
const qc = useQueryClient();
useEffect(() => {
if (typeof window === "undefined" || typeof EventSource === "undefined")
return;
client = qc;
refs += 1;
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
attach();
return release;
}, [qc]);
}
+18 -2
View File
@@ -39,15 +39,31 @@ export async function apiFetch<T>(
return body as T;
}
/** On lost session, send the user to the login screen, remembering where they were. */
/**
* On lost session, send the user to the login screen, remembering where they were.
*
* Deferred by a beat rather than navigating inline. This runs inside whichever call noticed the
* 401 very often a background poll the user never asked for and a synchronous
* `location.href =` there tears the page down mid-render, taking any unsaved editing state with it
* (the Displays page models exactly such a draft). Letting the current task finish first means the
* caller's own error handling still runs, and a `beforeunload` guard can still speak up.
*
* Guarded so a burst of parallel 401s (every card on a page polling at once) schedules one
* navigation, not one per request.
*/
let redirecting = false;
function redirectToLogin(): void {
if (typeof window === "undefined") return;
if (window.location.pathname === "/login") return;
if (redirecting) return;
redirecting = true;
// Keep the full path (query + hash too), so re-login returns to the exact view.
const next = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
window.location.href = `/login?next=${next}`;
setTimeout(() => {
window.location.href = `/login?next=${next}`;
}, 0);
}
function safeJson(text: string): unknown {
+51
View File
@@ -0,0 +1,51 @@
// Automation (event hooks). Read uses the generated query; the WRITE is hand-rolled because it
// carries the console password, which the BFF verifies and strips
// (server/routes/api/v1/hooks.put.ts) — a hook is a shell command the host will run on its own
// events, so a session cookie alone must not be able to install one.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/api/fetcher";
import { getGetHooksQueryKey } from "@/api/gen/hooks/hooks";
import type { HookEntry } from "@/api/gen/model/hookEntry";
/** The whole automation config is written at once — the host has no per-hook route. */
export function useSaveHooks() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
hooks,
password,
}: {
hooks: HookEntry[];
password: string;
}) =>
apiFetch<void>("/api/v1/hooks", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hooks, password }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetHooksQueryKey() });
},
});
}
/** A one-line description of what a hook does, for the list row. */
export function hookAction(h: HookEntry): string {
if (h.run) return h.run;
if (h.webhook) return h.webhook;
return "";
}
/** Human summary of a hook's filter, or "" when it matches everything. */
export function hookFilterSummary(h: HookEntry): string {
const f = h.filter;
if (!f) return "";
return [
f.client && `client=${f.client}`,
f.app && `app=${f.app}`,
f.plane && `plane=${f.plane}`,
f.fingerprint && `fp=${f.fingerprint.slice(0, 12)}`,
]
.filter(Boolean)
.join(" · ");
}
+42 -5
View File
@@ -46,16 +46,53 @@ const ICONS: Record<string, LucideIcon> = {
clapperboard: Clapperboard,
};
/** Resolve a registered icon name to a component (Puzzle fallback). */
export const pluginIcon = (name?: string): LucideIcon =>
(name ? ICONS[name] : undefined) ?? Puzzle;
/**
* Resolve a registered icon name to a component (Puzzle fallback).
*
* `name` comes from a plugin's own registration, so it is untrusted input to a lookup on a plain
* object and a plain object inherits from Object.prototype. `ICONS["constructor"]` is `Object`,
* which is truthy, so a `?? Puzzle` fallback never fires and React is handed `Object` as a
* component: it throws out of render, and because this runs inside the AppShell nav that takes
* down every page of the console. `Object.hasOwn` keeps the lookup to keys we actually declared.
*/
export const pluginIcon = (name?: string): LucideIcon => {
if (!name || !Object.hasOwn(ICONS, name)) return Puzzle;
return ICONS[name] ?? Puzzle;
};
/** The query key for the plugin directory — the nav is built from it. */
export const PLUGINS_KEY = ["plugins"] as const;
const IDLE_POLL_MS = 30_000;
const BOOST_POLL_MS = 2_000;
/** How long to keep polling fast after something changed the installed set. */
const BOOST_MS = 60_000;
/**
* Until this timestamp, poll the directory fast.
*
* A finished install is NOT the moment the plugin appears: the host restarts the scripting runner
* afterwards, and the plugin only registers its UI once that comes back several seconds later,
* and after any one-shot invalidation has already run and found the old list. So the nav sat
* unchanged until the 30 s idle poll happened to land, which in practice meant "until I reloaded".
*
* Module-level rather than component state because the two things that need to trigger it (a store
* job settling, a `plugins.changed`/`store.changed` event) both live outside the nav.
*/
let boostUntil = 0;
/** Poll the plugin directory fast for a while — call after anything that changes what's installed. */
export function boostPluginPolling(): void {
boostUntil = Date.now() + BOOST_MS;
}
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
export function usePlugins() {
return useQuery({
queryKey: ["plugins"],
queryKey: PLUGINS_KEY,
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
refetchInterval: 30_000,
refetchInterval: () =>
Date.now() < boostUntil ? BOOST_POLL_MS : IDLE_POLL_MS,
refetchOnWindowFocus: true,
});
}
+68 -4
View File
@@ -11,6 +11,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/api/fetcher";
import { boostPluginPolling } from "@/api/plugins";
/**
* How much a plugin's provenance is worth, from most to least trustworthy:
@@ -80,7 +81,9 @@ export interface StoreCatalog {
export interface InstalledPlugin {
pkg: string;
version: string;
/** Nullable in the contract (`InstalledView.version`) a CLI-installed plugin may carry no
* recorded version. Typed required here, the Installed tab rendered the literal "vundefined". */
version?: string | null;
tier: StoreTier;
source?: string;
entry_id?: string;
@@ -123,14 +126,24 @@ export interface JobAccepted {
job: string;
}
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
/**
* Install a curated catalog entry, or deliberately awkward a raw package spec.
*
* The raw-spec branch carries the console `password`: it runs unreviewed code, so the BFF
* re-confirms it (server/routes/api/v1/store/install.post.ts) and strips it before the host ever
* sees the request. A catalog install needs no password that trust decision was made when the
* source was added.
*/
export type InstallBody =
| { source: string; id: string }
| { spec: string; accept_unverified: true };
| { spec: string; accept_unverified: true; password: string };
/** Adding or repointing a source is a trust-root change, so it carries the console password too
* (stripped at the BFF server/routes/api/v1/store/sources/[name].put.ts). */
export interface SourceBody {
url: string;
public_key?: string;
password: string;
}
const BASE = "/api/v1/store";
@@ -156,6 +169,9 @@ const json = (method: string, body: unknown): RequestInit => ({
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
*/
export function invalidateStore(qc: QueryClient): Promise<void> {
// The runner restarts AFTER the job reports done, so the plugin registers its UI a few seconds
// from now — this invalidation would otherwise refetch the pre-install list and stop looking.
boostPluginPolling();
return Promise.all([
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
qc.invalidateQueries({ queryKey: storeKeys.installed }),
@@ -199,14 +215,62 @@ export function useStoreRuntime() {
/**
* A single install/uninstall job, polled once a second while it runs and left alone once it
* settles. Pass `null` to park the query (no job in flight).
*
* The interval keys off "not finished yet" rather than off `state === "running"`. `data` is
* undefined in two live cases the first poll has not landed, and the first poll FAILED and
* treating those as "stop polling" wedged the card: an install whose very first poll lost the race
* with a busy host never polled again and the operator saw nothing at all, while an install that
* restarts the runner (every successful one does) could drop a poll mid-flight.
*
* The failure count bounds it: jobs live in host memory, so a host restart makes the id 404 for
* good, and something has to stop asking.
*/
const JOB_POLL_MS = 1_000;
const JOB_MAX_FAILURES = 15;
/**
* The host's recent jobs, used to RE-ATTACH after a reload.
*
* The in-flight job id lived only in component state, so reloading the page (or opening the console
* on another device) lost all trace of a running install while the Install buttons stayed armed
* and the host takes one job at a time, so the next click just bounced off a 409. The host keeps
* the list; ask it rather than remembering.
*/
export function useStoreJobs() {
return useQuery({
queryKey: [...storeKeys.all, "jobs"] as const,
queryFn: () => apiFetch<StoreJob[]>(`${BASE}/jobs`),
// Only needed to find an orphaned job on mount; the job query itself does the live polling.
staleTime: 5_000,
});
}
/** The newest job that is still running, if any — what a fresh page should re-attach to. */
export function runningJob(jobs: StoreJob[] | undefined): StoreJob | undefined {
if (!jobs) return undefined;
// The list is oldest-first, so scan from the end for the most recent live one.
for (let i = jobs.length - 1; i >= 0; i--) {
const j = jobs[i];
if (j?.state === "running") return j;
}
return undefined;
}
export function useStoreJob(id: string | null) {
return useQuery({
queryKey: storeKeys.job(id ?? ""),
queryFn: () =>
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
enabled: id !== null,
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
refetchInterval: (q) => {
const state = q.state.data?.state;
if (state === "done" || state === "failed") return false;
if (q.state.fetchFailureCount > JOB_MAX_FAILURES) return false;
return JOB_POLL_MS;
},
// A job that vanished with its host is gone for good; a transient blip is not. Retry a few
// times per poll so a runner restart doesn't surface as an error card.
retry: 3,
});
}
+51 -19
View File
@@ -10,9 +10,11 @@ import {
ScrollText,
Server,
Settings,
Workflow,
} from "lucide-react";
import { motion, stagger } from "motion/react";
import { type ReactNode, useState } from "react";
import { useHostEvents } from "@/api/events";
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
import { BrandMark } from "@/components/brand-mark";
import { Wordmark } from "@/components/wordmark";
@@ -30,6 +32,7 @@ const NAV = [
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/automation", icon: Workflow, label: () => m.nav_automation() },
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const;
@@ -47,6 +50,10 @@ const MOBILE_OVERFLOW = NAV.slice(4);
export function AppShell({ children }: { children: ReactNode }) {
// Read the locale so the whole shell re-renders on a language switch.
useLocale();
// One subscription to the host's event stream for the whole console — it invalidates the queries
// each event affects, so pages update on the transition instead of on their own timer. The
// polling intervals stay as a floor in case the stream is unavailable.
useHostEvents();
return (
<div className="flex min-h-screen">
{/* Desktop sidebar ( sm). Sticky at viewport height: the page (body) scrolls with
@@ -136,32 +143,57 @@ function PluginNavSection() {
const plugins = uiPlugins(data);
if (plugins.length === 0) return null;
return (
<div className="mt-6 flex flex-col gap-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
// Its own animation container, with the same variants + stagger as the main nav above. These
// were plain links: the group sits OUTSIDE that `motion.nav`, so it inherited neither the
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
// (and a fresh install adds one to a nav that is already on screen), which is exactly when
// the animation earns its keep.
<motion.div
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
>
{m.nav_plugins()}
</p>
</motion.p>
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
// The motion wrapper is a DIV around the link, not `motion(Link)`: wrapping Link
// erases TanStack's typed `params`, and these entries need `$pluginId`.
<motion.div
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
variants={{
from: { opacity: 0, x: -20 },
enter: { opacity: 1, x: 0 },
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
<Link
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
</motion.div>
);
})}
</div>
</motion.div>
);
}
@@ -189,7 +221,7 @@ function MobileNav() {
{moreOpen && (
<button
type="button"
aria-label="Close menu"
aria-label={m.nav_close_menu()}
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
onClick={() => setMoreOpen(false)}
/>
@@ -270,7 +302,7 @@ function LanguageSwitcher() {
const current = useLocale();
return (
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
<div className="flex gap-1" role="group" aria-label="Language">
<div className="flex gap-1" role="group" aria-label={m.settings_language()}>
{locales.map((l: Locale) => (
<button
key={l}
+7 -1
View File
@@ -32,7 +32,13 @@ export function QueryState({
if (error) {
const unauthorized = error instanceof ApiError && error.status === 401;
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
// `role="alert"` so the failure is announced. The loading branch above already has
// role="status"; without this, a query that resolved into an error swapped one silent
// region for another and a screen-reader user was told only that loading had stopped.
<div
role="alert"
className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm"
>
<p className="font-medium text-destructive">
{unauthorized ? m.common_unauthorized() : m.common_error()}
</p>
+2 -1
View File
@@ -1,6 +1,7 @@
import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
// recreated from divs and animated as if orbiting on a path whose long axis points
@@ -76,7 +77,7 @@ export function Spinner({
<div
ref={ref}
role="status"
aria-label="Loading"
aria-label={m.common_loading()}
className={cn("relative inline-block size-6 isolate", className)}
{...props}
>
+20
View File
@@ -0,0 +1,20 @@
import { ApiError } from "@/api/fetcher";
/**
* The server's own `{ error }` message from a thrown `ApiError` (its `.data` body), for inline
* display falling back to the HTTP status text, then to whatever was thrown.
*
* The host writes genuinely useful refusals ("entry is owned by provider `x` update it through
* its reconcile"), and showing a generic "something went wrong" in their place throws away the one
* piece of information that tells the operator what to do next.
*
* Lives here rather than in a section because several of them need it; it started life private to
* the display card.
*/
export function apiErrorMessage(err: unknown): string | undefined {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
}
+45
View File
@@ -0,0 +1,45 @@
import { getLocale } from "@/paraglide/runtime";
/**
* Date/time and number formatting that follows the CONSOLE's locale, not the browser's.
*
* A bare `toLocaleString()` uses `navigator.language`, so a console switched to German still
* rendered US-style timestamps (and vice versa) the app said one thing and its dates another.
* `getLocale()` is Paraglide's resolved locale, which is what every string on screen uses.
*
* `Intl` formatters are expensive to construct and these run per table row, so they are cached
* per locale.
*/
const dateTimeCache = new Map<string, Intl.DateTimeFormat>();
function dateTimeFor(locale: string): Intl.DateTimeFormat {
let f = dateTimeCache.get(locale);
if (!f) {
f = new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
});
dateTimeCache.set(locale, f);
}
return f;
}
/** Unix MILLISECONDS → a locale date-time, or an em dash for "never". */
export function fmtDateTime(unixMs: number | undefined | null): string {
if (!unixMs) return "—";
return dateTimeFor(getLocale()).format(new Date(unixMs));
}
/** Unix SECONDS → a locale date-time (the store's `fetched_at` convention). */
export function fmtDateTimeSecs(unixSecs: number | undefined | null): string {
if (!unixSecs) return "—";
return fmtDateTime(unixSecs * 1000);
}
/** A number with the console locale's separators — never a hand-rolled `toFixed`. */
export function fmtNumber(value: number, digits = 0): string {
return new Intl.NumberFormat(getLocale(), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
}
+27 -2
View File
@@ -3,8 +3,8 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { ApiError } from "./api/fetcher";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const queryClient = new QueryClient({
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 2_000,
@@ -21,6 +21,31 @@ export function getRouter() {
},
},
});
}
/**
* The browser's ONE QueryClient.
*
* `getRouter()` can run more than once per page load (hydration discards and rebuilds the tree),
* and a fresh client each time means a fresh, empty cache that nothing else holds a reference to.
* That is how the event stream ended up invalidating a cache no component was reading: the
* subscription captured the client from the first router, the live pages read the second one, and
* every invalidation went to the dead one. One client per browser session fixes that and keeps the
* cache across a router rebuild.
*
* Deliberately browser-only: on the SERVER every request must get its OWN client, or one visitor's
* data would be served from another's cache.
*/
let browserQueryClient: QueryClient | undefined;
export function getRouter() {
let queryClient: QueryClient;
if (typeof window === "undefined") {
queryClient = createQueryClient();
} else {
if (!browserQueryClient) browserQueryClient = createQueryClient();
queryClient = browserQueryClient;
}
return createTanStackRouter({
routeTree,
+26 -8
View File
@@ -10,9 +10,10 @@ import {
} from "@tanstack/react-router";
import "@fontsource-variable/geist";
import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell";
import { adoptStoredLocale } from "@/lib/i18n";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url";
export interface RouterContext {
@@ -25,11 +26,19 @@ export const Route = createRootRouteWithContext<RouterContext>()({
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "color-scheme", content: "dark light" },
{ name: "theme-color", content: "#6c5bf3" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-title", content: "Punktfunk" },
{ title: "Punktfunk" },
],
links: [
{ rel: "stylesheet", href: appCss },
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
// Installable on a phone — this console is used from a couch as often as from a desk,
// and a home-screen launcher beats retyping a LAN IP. Standalone display, no service
// worker: an offline shell for a console whose every screen is live host state would
// only ever show stale numbers convincingly.
{ rel: "manifest", href: "/manifest.webmanifest" },
],
}),
component: RootComponent,
@@ -41,23 +50,32 @@ function RootComponent() {
useEffect(() => {
adoptStoredLocale();
}, []);
// `lang` must track the locale the page is actually rendered in — it is what tells a screen
// reader which pronunciation to use, and it was pinned to "en" while the app switched to German
// underneath it. `adoptStoredLocale` also sets it on the live document; this keeps SSR honest.
const locale = useLocale();
// The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login",
});
return (
<html lang="en" className="dark">
<html lang={locale} className="dark">
<head>
<HeadContent />
</head>
<body className="min-h-screen">
{isLogin ? (
<Outlet />
) : (
<AppShell>
{/* Motion defaults to `reducedMotion: "never"`, so every card, nav item and button
animated at full strength even for someone whose OS asks for less. "user" honours
the OS setting. */}
<MotionConfig reducedMotion="user">
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</MotionConfig>
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
<Toaster />
<Scripts />
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionAutomation } from "@/sections/Automation";
export const Route = createFileRoute("/automation")({
component: SectionAutomation,
});
+265
View File
@@ -0,0 +1,265 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { type FC, useEffect, useState } from "react";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts.
* Same vocabulary as the SSE `?kinds=` filter, so the two stay learnable together. */
export const EVENT_KINDS = [
"client.*",
"client.connected",
"client.disconnected",
"session.*",
"session.started",
"session.ended",
"stream.*",
"stream.started",
"stream.stopped",
"game.*",
"game.running",
"game.exited",
"pairing.*",
"pairing.pending",
"pairing.completed",
"pairing.denied",
"display.*",
"display.created",
"display.released",
"library.changed",
"update.available",
"update.applied",
"host.started",
"host.stopping",
] as const;
const EMPTY: HookEntry = { on: "session.started", run: "" };
/**
* Add or edit one hook.
*
* A hook is either a shell command or a webhook never both in this form, because "run this AND
* post that" is two hooks and pretending otherwise makes the failure modes impossible to reason
* about. The action kind is therefore a choice, not two optional fields.
*/
export const HookForm: FC<{
/** The hook being edited, `EMPTY`-seeded for a new one, or null when closed. */
value: HookEntry | null;
onCancel: () => void;
onSave: (hook: HookEntry) => void;
}> = ({ value, onCancel, onSave }) => {
const [draft, setDraft] = useState<HookEntry>(EMPTY);
const [kind, setKind] = useState<"run" | "webhook">("run");
const [filtered, setFiltered] = useState(false);
// Re-seed whenever a different hook is opened (the dialog stays mounted between edits).
useEffect(() => {
if (!value) return;
setDraft(value);
setKind(value.webhook ? "webhook" : "run");
setFiltered(!!value.filter);
}, [value]);
const set = (patch: Partial<HookEntry>) =>
setDraft((d) => ({ ...d, ...patch }));
const action = kind === "run" ? (draft.run ?? "") : (draft.webhook ?? "");
const ready = draft.on.trim().length > 0 && action.trim().length > 0;
const commit = () => {
// Emit exactly one action field, and drop an unticked filter entirely — leaving `{}` behind
// would read as "filter on nothing" to anyone reading the config file later.
const out: HookEntry = {
on: draft.on.trim(),
...(kind === "run"
? { run: action.trim(), webhook: null }
: { webhook: action.trim(), run: null }),
...(filtered && draft.filter ? { filter: draft.filter } : {}),
...(draft.debounce_ms ? { debounce_ms: draft.debounce_ms } : {}),
...(draft.timeout_s ? { timeout_s: draft.timeout_s } : {}),
...(kind === "webhook" && draft.hmac_secret_file
? { hmac_secret_file: draft.hmac_secret_file }
: {}),
};
onSave(out);
};
return (
<Dialog open={value !== null} onOpenChange={(o) => !o && onCancel()}>
<DialogContent className="max-h-[85vh] max-w-xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{m.automation_hook_title()}</DialogTitle>
<DialogDescription>{m.automation_hook_help()}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
<select
id="hook-on"
value={draft.on}
onChange={(e) => set({ on: e.target.value })}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
{EVENT_KINDS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
{m.automation_field_on_help()}
</p>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{m.automation_field_action()}
</legend>
<div className="flex gap-2">
{(["run", "webhook"] as const).map((k) => (
<Button
key={k}
type="button"
size="sm"
variant={kind === k ? "default" : "outline"}
aria-pressed={kind === k}
onClick={() => setKind(k)}
>
{k === "run"
? m.automation_action_run()
: m.automation_action_webhook()}
</Button>
))}
</div>
<Input
id="hook-action"
aria-label={m.automation_field_action()}
autoComplete="off"
spellCheck={false}
value={action}
placeholder={
kind === "run" ? "/usr/local/bin/on-stream.sh" : "https://…"
}
onChange={(e) =>
set(
kind === "run"
? { run: e.target.value }
: { webhook: e.target.value },
)
}
/>
<p className="text-xs text-muted-foreground">
{kind === "run"
? m.automation_action_run_help()
: m.automation_action_webhook_help()}
</p>
</fieldset>
{kind === "webhook" && (
<div className="space-y-2">
<Label htmlFor="hook-hmac">{m.automation_field_hmac()}</Label>
<Input
id="hook-hmac"
autoComplete="off"
spellCheck={false}
value={draft.hmac_secret_file ?? ""}
onChange={(e) => set({ hmac_secret_file: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
{m.automation_field_hmac_help()}
</p>
</div>
)}
<Label className="flex items-start gap-3 text-sm font-normal">
<Checkbox
checked={filtered}
onCheckedChange={(n) => setFiltered(n === true)}
className="mt-0.5"
/>
<span>{m.automation_field_filter()}</span>
</Label>
{filtered && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hook-client">
{m.automation_filter_client()}
</Label>
<Input
id="hook-client"
value={draft.filter?.client ?? ""}
onChange={(e) =>
set({ filter: { ...draft.filter, client: e.target.value } })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="hook-app">{m.automation_filter_app()}</Label>
<Input
id="hook-app"
value={draft.filter?.app ?? ""}
onChange={(e) =>
set({ filter: { ...draft.filter, app: e.target.value } })
}
/>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hook-debounce">
{m.automation_field_debounce()}
</Label>
<Input
id="hook-debounce"
type="number"
min={0}
value={draft.debounce_ms ?? 0}
onChange={(e) =>
set({ debounce_ms: Number(e.target.value) || 0 })
}
/>
</div>
{kind === "run" && (
<div className="space-y-2">
<Label htmlFor="hook-timeout">
{m.automation_field_timeout()}
</Label>
<Input
id="hook-timeout"
type="number"
min={1}
max={600}
value={draft.timeout_s ?? 30}
onChange={(e) =>
set({ timeout_s: Number(e.target.value) || 30 })
}
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{m.common_cancel()}
</Button>
<Button disabled={!ready} onClick={commit}>
{m.automation_hook_save()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+270
View File
@@ -0,0 +1,270 @@
import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { Pencil, Plus, Terminal, Trash2, Webhook } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import { useGetHooks } from "@/api/gen/hooks/hooks";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { HookForm } from "./HookForm";
/**
* **Automation** the operator's event hooks (`GET/PUT /api/v1/hooks`).
*
* The host has run these since the API existed and the console never showed them: the only way to
* see or change what your machine does when a stream starts was to edit the config file by hand.
*
* The whole list is written in one PUT (the host has no per-hook route), so this edits a local copy
* and saves explicitly no auto-save. That is deliberate for a screen whose contents are shell
* commands: a half-typed command should never reach the host because a poll landed.
*/
export const SectionAutomation: FC = () => {
useLocale();
const query = useGetHooks();
const save = useSaveHooks();
const [hooks, setHooks] = useState<HookEntry[] | null>(null);
const [editing, setEditing] = useState<{
index: number;
hook: HookEntry;
} | null>(null);
const [confirming, setConfirming] = useState(false);
const [password, setPassword] = useState("");
const [wrongPassword, setWrongPassword] = useState(false);
// Seed once. Unlike the display card there is no re-seed-when-clean dance: nothing else in the
// console writes hooks, so the server value cannot move underneath an edit.
const server = query.data?.hooks;
useEffect(() => {
if (hooks === null && server) setHooks(server);
}, [server, hooks]);
const list = hooks ?? [];
const dirty =
hooks !== null && JSON.stringify(hooks) !== JSON.stringify(server ?? []);
const upsert = (hook: HookEntry) => {
if (!editing) return;
setHooks((prev) => {
const next = [...(prev ?? [])];
if (editing.index < 0) next.push(hook);
else next[editing.index] = hook;
return next;
});
setEditing(null);
};
const remove = (index: number) => {
if (!confirm(m.automation_delete_confirm())) return;
setHooks((prev) => (prev ?? []).filter((_, i) => i !== index));
};
const commit = async () => {
setWrongPassword(false);
try {
await save.mutateAsync({ hooks: list, password });
setConfirming(false);
setPassword("");
toast.success(m.automation_saved());
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
setWrongPassword(true);
return;
}
toast.error(m.automation_save_failed());
}
};
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.automation_title()}</h1>
<p className="max-w-prose text-sm text-muted-foreground">
{m.automation_subtitle()}
</p>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>{m.automation_hooks_title()}</CardTitle>
<Button
size="sm"
variant="outline"
onClick={() =>
setEditing({
index: -1,
hook: { on: "session.started", run: "" },
})
}
>
<Plus className="size-4" />
{m.automation_add()}
</Button>
</CardHeader>
<CardContent className="space-y-3">
<QueryState
isLoading={query.isLoading}
error={query.error}
refetch={query.refetch}
>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">
{m.automation_empty()}
</p>
) : (
<ul className="flex flex-col gap-2">
{list.map((h, i) => (
<li
// The list is operator-ordered and has no ids; the index IS the identity
// here, and rows only move when the operator moves them.
key={`${h.on}:${hookAction(h)}:${i}`}
className="flex items-start gap-3 rounded-lg border p-3"
>
{h.webhook ? (
<Webhook className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
) : (
<Terminal className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{h.on}</Badge>
{hookFilterSummary(h) && (
<Badge variant="outline">
{hookFilterSummary(h)}
</Badge>
)}
{!!h.debounce_ms && (
<Badge variant="outline">
{m.automation_debounce_badge({
ms: h.debounce_ms,
})}
</Badge>
)}
</div>
<p className="truncate font-mono text-xs text-muted-foreground">
{hookAction(h)}
</p>
</div>
<Button
variant="ghost"
size="icon"
aria-label={m.automation_edit()}
onClick={() => setEditing({ index: i, hook: h })}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.automation_delete()}
onClick={() => remove(i)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</li>
))}
</ul>
)}
</QueryState>
{dirty && (
<div className="flex flex-wrap items-center gap-3 rounded-md bg-[var(--warning)]/10 px-3 py-2">
<span className="text-sm font-medium">
{m.automation_unsaved()}
</span>
<div className="ml-auto flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setHooks(server ?? [])}
>
{m.display_revert()}
</Button>
<Button size="sm" onClick={() => setConfirming(true)}>
{m.display_save()}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
<HookForm
value={editing?.hook ?? null}
onCancel={() => setEditing(null)}
onSave={upsert}
/>
{/* Saving installs commands the host will run on its own same bar as an update or an
unreviewed install, so the same password. */}
<Dialog
open={confirming}
onOpenChange={(o) => {
if (!o) {
setConfirming(false);
setWrongPassword(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{m.automation_confirm_title()}</DialogTitle>
<DialogDescription>{m.automation_confirm_body()}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="automation-password">
{m.store_spec_password()}
</Label>
<Input
id="automation-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{wrongPassword && (
<p role="alert" className="text-xs text-destructive">
{m.update_apply_wrong_password()}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setConfirming(false);
setWrongPassword(false);
}}
>
{m.common_cancel()}
</Button>
<Button
disabled={save.isPending || password.length === 0}
onClick={commit}
>
{m.display_save()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Section>
);
};
+127
View File
@@ -0,0 +1,127 @@
import { Activity as ActivityIcon } from "lucide-react";
import type { FC } from "react";
import { type ActivityEntry, useActivity } from "@/api/events";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fmtDateTime } from "@/lib/format";
import { m } from "@/paraglide/messages";
/**
* What this host has been doing the event stream, rendered.
*
* The console could describe the present (a status snapshot) but never the recent past: a client
* that connected and left while you were on another page left no trace anywhere you could look.
* The stream was already open for cache invalidation, so this costs one ring buffer.
*
* In-memory and bounded, so it starts empty on a page load and fills as things happen. That is the
* honest shape for a live tail pretending to be a durable log would need the host to keep one.
*/
export const ActivityCard: FC = () => {
const entries = useActivity();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ActivityIcon className="size-4" />
{m.activity_title()}
</CardTitle>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.activity_empty()}</p>
) : (
<ul className="flex flex-col divide-y">
{entries.map((e) => (
<li
key={e.seq}
className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0"
>
<Badge variant={toneFor(e.kind)}>{kindLabel(e.kind)}</Badge>
<span className="min-w-0 flex-1 truncate text-sm">
{describe(e)}
</span>
<time
dateTime={new Date(e.ts_ms).toISOString()}
className="shrink-0 text-xs tabular-nums text-muted-foreground"
>
{fmtDateTime(e.ts_ms)}
</time>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
};
/** The subject of an event, in one line — whatever the payload actually names. */
function describe(e: ActivityEntry): string {
const d = e.data;
const client = pick(d.client, "name") ?? pick(d.session, "client");
const stream = d.stream as Record<string, unknown> | undefined;
const parts = [
client,
typeof stream?.app === "string" ? stream.app : undefined,
typeof d.reason === "string" ? d.reason : undefined,
typeof d.game === "string" ? d.game : undefined,
].filter((x): x is string => typeof x === "string" && x.length > 0);
// An event whose payload names nothing (host.started, library.changed) is still worth a row —
// the kind badge carries the whole meaning, so leave the line blank rather than inventing text.
return parts.join(" · ");
}
/** Read a string field off a nested ref object, tolerating anything unexpected. */
function pick(obj: unknown, key: string): string | undefined {
if (!obj || typeof obj !== "object") return undefined;
const v = (obj as Record<string, unknown>)[key];
if (typeof v === "string") return v;
// `SessionRef.client` is itself a ClientRef.
if (v && typeof v === "object") {
const name = (v as Record<string, unknown>).name;
return typeof name === "string" ? name : undefined;
}
return undefined;
}
/** Colour by what the event means, not by its domain — good news green, losses muted, denials red. */
function toneFor(
kind: string,
): "success" | "destructive" | "secondary" | "outline" {
if (kind === "pairing.denied") return "destructive";
if (kind.endsWith(".connected") || kind.endsWith(".started"))
return "success";
if (kind === "pairing.completed") return "success";
if (kind.endsWith(".disconnected") || kind.endsWith(".ended"))
return "outline";
if (kind.endsWith(".stopped") || kind.endsWith(".exited")) return "outline";
return "secondary";
}
/** Translated label per kind, falling back to the raw kind so a new host event still shows. */
const KIND_LABEL: Record<string, () => string> = {
"client.connected": () => m.activity_client_connected(),
"client.disconnected": () => m.activity_client_disconnected(),
"session.started": () => m.activity_session_started(),
"session.ended": () => m.activity_session_ended(),
"stream.started": () => m.activity_stream_started(),
"stream.stopped": () => m.activity_stream_stopped(),
"game.running": () => m.activity_game_running(),
"game.exited": () => m.activity_game_exited(),
"pairing.pending": () => m.activity_pairing_pending(),
"pairing.completed": () => m.activity_pairing_completed(),
"pairing.denied": () => m.activity_pairing_denied(),
"display.created": () => m.activity_display_created(),
"display.released": () => m.activity_display_released(),
"library.changed": () => m.activity_library_changed(),
"update.available": () => m.activity_update_available(),
"update.applied": () => m.activity_update_applied(),
"plugins.changed": () => m.activity_plugins_changed(),
"store.changed": () => m.activity_store_changed(),
"host.started": () => m.activity_host_started(),
"host.stopping": () => m.activity_host_stopping(),
};
function kindLabel(kind: string): string {
return KIND_LABEL[kind]?.() ?? kind;
}
+5 -3
View File
@@ -31,11 +31,13 @@ export const RunningGames: FC<{
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{games.map((g) => (
{games.map((g, i) => (
<GameRow
// A host can run the same title for two clients at once (native admits concurrent
// sessions), so the title alone is not a key.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}`}
// sessions), so the title alone is not a key. The index is the last resort: every
// grace row has a null `session_id`, so two waiting copies of the same title on the
// same plane produced identical keys and React collapsed them into one row.
key={`${g.plane}:${g.session_id ?? "grace"}:${g.app_id ?? g.title}:${i}`}
game={g}
art={coverFor(g, library)}
onEnd={() => onEnd(g)}
+62 -7
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host";
import { useGetLibrary } from "@/api/gen/library/library";
@@ -8,14 +9,26 @@ import {
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
useLocale();
const qc = useQueryClient();
// Poll live status every 2s so the console tracks an active session.
const status = useGetStatus({ query: { refetchInterval: 2_000 } });
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
// safety net in case the stream is unavailable.
const status = useGetStatus({
query: {
refetchInterval: (q) =>
q.state.data?.video_streaming || (q.state.data?.games?.length ?? 0) > 0
? 2_000
: 15_000,
},
});
// The catalog, for the running-game card's box art. Fetched once and held: a library scan touches
// every installed store's on-disk metadata, so it must not ride the 2 s status poll.
const library = useGetLibrary(undefined, {
@@ -28,29 +41,71 @@ export const SectionDashboard: FC = () => {
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetStatusQueryKey() });
/** Every session control reports its failure. These are the console's most consequential
* buttons stopping a session, ending a game and a refusal used to be completely silent. */
const failed = (fallback: string) => (e: unknown) =>
toast.error(apiErrorMessage(e) ?? fallback);
/**
* "End now" means two different things, and which one is right follows from the row's state: a
* game whose session is still live ends by stopping that session (what then happens to the game
* follows the operator's policy stopping a session is not licence to close a game), while a
* game already waiting out its reconnect window has no session left to stop and is ended directly.
*
* Both paths are wider than the row they are attached to, and neither used to say so:
*
* - `DELETE /session` is the host's ONLY stop and it tears down every live session
* (mgmt/session.rs calls `quit_session` AND `session_status::stop_all_quit`). With two people
* streaming, "End now" on one row kicked both. There is no per-session stop to call instead,
* so the honest fix is to name the blast radius before doing it.
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
* row for an operator-typed command carries no `app_id` so that row ended all of them.
*/
const onEndGame = (game: ActiveGame) => {
const games = status.data?.games ?? [];
if (game.state === "grace") {
const waiting = games.filter((g) => g.state === "grace").length;
if (
!game.app_id &&
waiting > 1 &&
!confirm(m.games_end_all_waiting_confirm({ count: waiting }))
)
return;
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate },
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
);
} else {
stop.mutate(undefined, { onSuccess: invalidate });
return;
}
if (!confirmStopAll()) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
});
};
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
* session live, stopping is not a per-client action and the operator has to know that. */
const confirmStopAll = (): boolean => {
const active = status.data?.active_sessions ?? 0;
if (active <= 1) return true;
return confirm(m.action_stop_session_all_confirm({ count: active }));
};
return (
<DashboardView
status={status}
library={library.data}
onStopSession={() => stop.mutate(undefined, { onSuccess: invalidate })}
onRequestIdr={() => idr.mutate(undefined)}
onStopSession={() => {
if (!confirmStopAll()) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
});
}}
onRequestIdr={() =>
idr.mutate(undefined, { onError: failed(m.action_idr_failed()) })
}
onEndGame={onEndGame}
isStopping={stop.isPending}
isRequestingIdr={idr.isPending}
+39 -2
View File
@@ -8,8 +8,10 @@ import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fmtNumber } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { ActivityCard } from "./Activity";
import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
@@ -73,8 +75,13 @@ export const DashboardView: FC<{
<span className="text-sm text-muted-foreground">
{m.status_pin_pending()}
</span>
{/* The whole value used to be "" or "": no text, no state, colour
doing all the work nothing for a screen reader to read out and
nothing for anyone who can't tell the two badges apart. */}
<Badge variant={s.pin_pending ? "default" : "outline"}>
{s.pin_pending ? "●" : "—"}
{s.pin_pending
? m.status_pin_waiting()
: m.status_pin_none()}
</Badge>
</CardContent>
</Card>
@@ -138,7 +145,34 @@ export const DashboardView: FC<{
/>
<Field
label={m.stream_bitrate()}
value={`${(s.stream.bitrate_kbps / 1000).toFixed(1)} Mbps`}
value={`${fmtNumber(s.stream.bitrate_kbps / 1000, 1)} Mbps`}
/>
{/* Bring-up and reconfigure cost, the parity floor and the packet
size: the host has reported all four for as long as this
endpoint has existed and the console showed none of them, so
"it takes ages to start" and "it hitches when I resize" had no
number attached anywhere. Native-plane only null on
GameStream and null until the first frame lands, so the two
timings appear only once they mean something. */}
{s.stream.time_to_first_frame_ms != null && (
<Field
label={m.stream_first_frame()}
value={`${fmtNumber(s.stream.time_to_first_frame_ms)} ms`}
/>
)}
{s.stream.last_resize_ms != null && (
<Field
label={m.stream_last_resize()}
value={`${fmtNumber(s.stream.last_resize_ms)} ms`}
/>
)}
<Field
label={m.stream_packet_size()}
value={`${fmtNumber(s.stream.packet_size)} B`}
/>
<Field
label={m.stream_min_fec()}
value={fmtNumber(s.stream.min_fec)}
/>
</dl>
) : (
@@ -148,6 +182,9 @@ export const DashboardView: FC<{
)}
</CardContent>
</Card>
{/* Below the session card: the past, under the present. */}
<ActivityCard />
</div>
)}
</QueryState>
+221 -78
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { useBlocker } from "@tanstack/react-router";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
@@ -7,10 +8,10 @@ import {
type MouseEvent,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDisplaySettingsQueryKey,
getGetDisplayStateQueryKey,
@@ -41,6 +42,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -96,6 +98,53 @@ export const DisplaySection: FC = () => {
},
);
/**
* Save the hand-edited Custom block.
*
* `capture_monitor` (the streamed-screen pin) belongs to the monitor picker below, not to this
* form but it is a field of the same policy object, so a draft seeded before the operator
* changed the streamed screen still carried the OLD value and Save quietly put it back. Defer
* that one axis to whatever the server currently reports.
*/
/** The streamed-screen pin as the HOST currently has it. Every write path defers to this rather
* than to the draft: the draft is only re-seeded while it is CLEAN, so once the operator has an
* unsaved edit its `capture_monitor` is frozen at whatever it was before they used the picker
* below and any write that spreads the draft would put the old pin back. */
const serverCaptureMonitor = () => q.data?.settings.capture_monitor ?? null;
const saveDraft = () => {
if (!draft) return;
apply({ ...draft, capture_monitor: serverCaptureMonitor() });
};
/**
* Apply ONE orthogonal axis game-session, DDC, PnP without dragging unsaved Custom edits
* along for the ride.
*
* These three controls apply immediately by design, but they used to send `{...draft}`: flipping
* DDC while the Custom block held unsaved edits committed those edits too, and the shared
* `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge so
* the operator got a policy they never saved with no trace it had happened. Send the axis on top
* of the last SAVED policy, and merge only that axis back into the draft.
*/
const applyAxis = (patch: Partial<DisplayPolicy>) => {
const base = seeded.current ?? draft;
if (!base) return;
// Reflect the flip straight away, keeping every other unsaved edit intact.
setDraft((d) => (d ? { ...d, ...patch } : d));
save.mutate(
{ data: { ...base, capture_monitor: serverCaptureMonitor(), ...patch } },
{
onSuccess: (res) => {
seeded.current = res.settings;
setDraft((d) => (d ? { ...d, ...patch } : res.settings));
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
toast.success(m.display_settings_saved());
},
},
);
};
// Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental
// toggle), so the draft can silently diverge from what the host is actually running. Reading the
// ref during render is safe here because every write to it is paired with a `setDraft`, so a
@@ -108,14 +157,17 @@ export const DisplaySection: FC = () => {
if (seeded.current) setDraft(seeded.current);
};
// Last line of defence: a reload/close with pending edits loses them silently otherwise. The
// browser shows its own generic wording — the text is ignored, only returning a value counts.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
// Don't lose pending edits, whichever way the operator leaves.
//
// This used to be a bare `beforeunload` listener, which only covers a reload or a tab close —
// clicking "Host" in the sidebar is a client-side route change the browser never hears about,
// so the draft vanished with no prompt at all. The router's blocker covers in-app navigation
// AND still arms `beforeunload` for the reload case, so it replaces the listener outright.
useBlocker({
shouldBlockFn: () => !confirm(m.display_discard_confirm()),
enableBeforeUnload: () => dirty,
disabled: !dirty,
});
return (
<div className="flex flex-col gap-card">
@@ -132,18 +184,34 @@ export const DisplaySection: FC = () => {
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_displays_help()}
</p>
{/* Once the form is on screen, a FAILED BACKGROUND POLL must not replace it the
operator may be mid-edit, and swapping the card for an error box throws the
draft away to report a refetch we could simply retry. Only a failure with
nothing to show is worth the error state. */}
<QueryState
isLoading={q.isLoading}
error={q.error}
error={draft ? undefined : q.error}
refetch={q.refetch}
>
{draft && q.error && (
<p
role="status"
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm"
>
{m.display_refresh_failed()}
</p>
)}
{q.data && draft && (
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
customPresets={q.data.custom_presets}
serverEffective={q.data.effective}
serverCaptureMonitor={serverCaptureMonitor}
apply={apply}
applyAxis={applyAxis}
saveDraft={saveDraft}
busy={save.isPending}
dirty={dirty}
revert={revert}
@@ -180,7 +248,15 @@ const DisplayForm: FC<{
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
customPresets: CustomPreset[];
/** What the host reports as IN FORCE right now — not derived from the local draft. */
serverEffective: EffectivePolicy;
/** The streamed-screen pin as the host has it — the draft's copy goes stale while dirty. */
serverCaptureMonitor: () => string | null;
apply: (p: DisplayPolicy) => void;
/** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */
applyAxis: (patch: Partial<DisplayPolicy>) => void;
/** Commit the Custom block, deferring axes this form does not own to the server's value. */
saveDraft: () => void;
busy: boolean;
/** The draft differs from what the host has stored — drives the save bar + the discard guard. */
dirty: boolean;
@@ -192,7 +268,11 @@ const DisplayForm: FC<{
setDraft,
presets,
customPresets,
serverEffective,
serverCaptureMonitor,
apply,
applyAxis,
saveDraft,
busy,
dirty,
revert,
@@ -254,8 +334,8 @@ const DisplayForm: FC<{
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
// Which screen we stream is not a display-behavior axis at all — swapping the
// streamed screen out from under the operator because they changed a preset would be
// the worst kind of surprise.
capture_monitor: draft.capture_monitor ?? null,
// the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor).
capture_monitor: serverCaptureMonitor(),
});
} else {
apply({ ...draft, preset: id as Preset });
@@ -277,8 +357,9 @@ const DisplayForm: FC<{
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
// the draft, so anything not named here is silently dropped — which is exactly how
// applying a saved preset used to switch a mirroring host back to a virtual display
// (found on-glass, .136). Every orthogonal axis has to be listed.
capture_monitor: draft.capture_monitor ?? null,
// (found on-glass, .136). Every orthogonal axis has to be listed, and this one comes
// from the SERVER (see serverCaptureMonitor).
capture_monitor: serverCaptureMonitor(),
});
};
@@ -487,11 +568,13 @@ const DisplayForm: FC<{
<Field
label={m.display_keep_alive()}
help={m.display_keep_alive_help()}
group
>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={ka.mode === "off" ? "default" : "outline"}
aria-pressed={ka.mode === "off"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "off" } })
@@ -502,6 +585,7 @@ const DisplayForm: FC<{
<Button
size="sm"
variant={ka.mode === "duration" ? "default" : "outline"}
aria-pressed={ka.mode === "duration"}
disabled={busy}
onClick={() =>
setDraft({
@@ -515,6 +599,7 @@ const DisplayForm: FC<{
<Button
size="sm"
variant={ka.mode === "forever" ? "default" : "outline"}
aria-pressed={ka.mode === "forever"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "forever" } })
@@ -525,6 +610,8 @@ const DisplayForm: FC<{
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
id="display-keep-alive-seconds"
aria-label={m.display_keep_alive_seconds()}
type="number"
min={0}
className="w-24"
@@ -594,8 +681,9 @@ const DisplayForm: FC<{
}
/>
<Field label={m.display_max()}>
<Field label={m.display_max()} htmlFor="display-max">
<Input
id="display-max"
type="number"
min={1}
max={16}
@@ -637,7 +725,7 @@ const DisplayForm: FC<{
{m.display_revert()}
</Button>
)}
<Button onClick={() => apply(draft)} disabled={busy || !dirty}>
<Button onClick={saveDraft} disabled={busy || !dirty}>
{m.display_save()}
</Button>
</div>
@@ -654,11 +742,7 @@ const DisplayForm: FC<{
options={["auto", "dedicated"]}
labels={GAME_SESSION_LABEL}
disabled={busy}
onPick={(v) => {
const next = { ...draft, game_session: v as GameSession };
setDraft(next);
apply(next);
}}
onPick={(v) => applyAxis({ game_session: v as GameSession })}
/>
</div>
@@ -671,11 +755,7 @@ const DisplayForm: FC<{
offLabel={m.display_ddc_disabled()}
onLabel={m.display_ddc_enabled()}
busy={busy}
onSet={(on) => {
const next = { ...draft, ddc_power_off: on };
setDraft(next);
apply(next);
}}
onSet={(on) => applyAxis({ ddc_power_off: on })}
/>
<ExperimentalToggle
label={m.display_pnp()}
@@ -684,32 +764,32 @@ const DisplayForm: FC<{
offLabel={m.display_pnp_disabled()}
onLabel={m.display_pnp_enabled()}
busy={busy}
onSet={(on) => {
const next = { ...draft, pnp_disable_monitors: on };
setDraft(next);
apply(next);
}}
onSet={(on) => applyAxis({ pnp_disable_monitors: on })}
/>
{/* What's in force right now */}
{/* What's in force right now read from the API's `effective`, not from the local draft.
Deriving it from the draft meant the row restated the operator's unsaved edits back to
them as though the host had already adopted them. */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">
{m.display_effective()}:
</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">
{tr(TOPOLOGY_LABEL, effective.topology)}
{fmtKeepAlive(serverEffective.keep_alive)}
</Badge>
<Badge variant="secondary">
{tr(TOPOLOGY_LABEL, serverEffective.topology)}
</Badge>
<Badge variant="outline">
{tr(CONFLICT_LABEL, effective.mode_conflict)}
{tr(CONFLICT_LABEL, serverEffective.mode_conflict)}
</Badge>
<Badge variant="outline">
{tr(IDENTITY_LABEL, effective.identity)}
{tr(IDENTITY_LABEL, serverEffective.identity)}
</Badge>
<Badge variant="outline">
{tr(LAYOUT_LABEL, effective.layout.mode)}
{tr(LAYOUT_LABEL, serverEffective.layout.mode)}
</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
<Badge variant="outline">{`${serverEffective.max_displays}×`}</Badge>
{(draft.game_session ?? "auto") === "dedicated" && (
<Badge variant="secondary">
{m.display_game_session_dedicated()}
@@ -735,19 +815,46 @@ const DisplayForm: FC<{
/** A labeled config field label, then the control, then optional help. The single source of the
* labelcontrolhelp spacing so every field (keep-alive, the button groups, max-displays) lines up. */
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
/** The id of the single control this labels, when there is one — see below. */
htmlFor?: string;
/** Set when the field wraps a GROUP of controls rather than one input. */
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const helpId = help && htmlFor ? `${htmlFor}-help` : undefined;
const helpText = help && (
<p id={helpId} className="max-w-prose text-xs text-muted-foreground">
{help}
</p>
);
// A set of related buttons IS a fieldset, so say so with the element rather than an ARIA role.
// (The single-control case keeps a plain <label for>, which is the right pairing there.)
if (group) {
return (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{helpText}
</fieldset>
);
}
// A bare <Label> with no `htmlFor` next to an <input> with no `id` labels nothing at all: a
// screen reader announced these as unnamed spin buttons.
return (
<div className="space-y-3">
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{helpText}
</div>
);
};
/**
* An Experimental-badged on/off policy toggle (the DDC/CI and PnP monitor axes) rendered outside
@@ -763,19 +870,21 @@ const ExperimentalToggle: FC<{
onSet: (v: boolean) => void;
}> = ({ label, help, value, offLabel, onLabel, busy, onSet }) => (
<div className="border-t pt-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="block">{label}</Label>
{/* A labelled group: the pair of buttons is one control, and the label belongs to both. */}
<fieldset className="space-y-3">
<legend className="mb-3 flex items-center gap-2 text-sm font-medium leading-none">
{label}
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
{m.display_experimental()}
</Badge>
</div>
</legend>
<div className="flex flex-wrap gap-2">
{([false, true] as const).map((on) => (
<Button
key={String(on)}
size="sm"
variant={value === on ? "default" : "outline"}
aria-pressed={value === on}
disabled={busy}
onClick={() => onSet(on)}
>
@@ -784,7 +893,7 @@ const ExperimentalToggle: FC<{
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
</div>
</fieldset>
</div>
);
@@ -798,13 +907,17 @@ const Choice: FC<{
disabled: boolean;
onPick: (v: string) => void;
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
<Field label={label} help={help}>
<Field label={label} help={help} group>
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o}
size="sm"
variant={value === o ? "default" : "outline"}
// Which option is active was signalled by fill colour alone — invisible to a screen
// reader, and to anyone who can't separate the two variants. `aria-pressed` states it.
// (The sibling Choice in SessionGameCard already did this; these did not.)
aria-pressed={value === o}
disabled={disabled}
onClick={() => onPick(o)}
>
@@ -842,8 +955,13 @@ const CustomPresetCard: FC<{
tabIndex={busy ? -1 : 0}
aria-pressed={selected}
aria-disabled={busy || undefined}
aria-label={m.display_preset_apply_named({ name: preset.name })}
onClick={() => !busy && onApply()}
onKeyDown={(e) => {
// Only when the CARD itself has focus. Keydown bubbles, so Enter/Space on the
// rename/update/delete icons inside it also reached here and applied the preset
// instead of running the icon's action — a keyboard user could not delete a preset.
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!busy) onApply();
@@ -918,7 +1036,17 @@ const CustomPresetCard: FC<{
*/
const LiveDisplays: FC = () => {
const qc = useQueryClient();
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
// Create/release arrive on the event stream (api/events.ts), so the timer is only here for the
// one thing events cannot express: the per-second "tears down in Ns" countdown on a lingering
// display. With nothing lingering it drops to a slow safety net.
const state = useGetDisplayState({
query: {
refetchInterval: (q) =>
q.state.data?.displays?.some((d) => d.expires_in_ms != null)
? 2_000
: 15_000,
},
});
const release = useReleaseDisplay();
const displays = state.data?.displays ?? [];
const kept = displays.filter((d) => d.state !== "active");
@@ -986,22 +1114,44 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
}) => {
const qc = useQueryClient();
const saveLayout = useSetDisplayLayout();
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = displays.filter((d) => d.identity_slot != null);
const settings = useGetDisplaySettings();
// Every position the host has on file — including devices that are not connected right now.
// `PUT /display/layout` REPLACES the whole map (`with_manual_layout` in pf-vdisplay builds a
// fresh `Layout`), so anything missing from our payload is deleted. Seeding only from the live
// displays therefore wiped the saved placement of every device that happened to be offline.
const saved = settings.data?.settings.layout?.positions;
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = useMemo(
() => displays.filter((d) => d.identity_slot != null),
[displays],
);
// Local edit buffer keyed by identity-slot string → {x, y}. `arrangeable` is memoised, and React
// Query's structural sharing keeps `displays` identity-stable across polls that changed nothing,
// so this effect runs when the set of displays actually changes rather than on every poll. It is
// idempotent regardless — it only ever fills in slots it has not seen before.
const [pos, setPos] = useState<Record<
string,
{ x: number; y: number }
> | null>(null);
useEffect(() => {
if (pos === null && arrangeable.length > 0) {
const seed: Record<string, { x: number; y: number }> = {};
for (const d of arrangeable)
seed[String(d.identity_slot)] = { x: d.x, y: d.y };
setPos(seed);
}
}, [arrangeable, pos]);
if (arrangeable.length === 0) return;
setPos((prev) => {
// Seed a display the first time we see it, and never re-seed one the operator may have
// since edited: a display that appears mid-edit used to be left out of the buffer entirely
// and so dropped from the save.
const next = { ...(prev ?? {}) };
let changed = prev === null;
for (const d of arrangeable) {
const k = String(d.identity_slot);
if (!(k in next)) {
next[k] = { x: d.x, y: d.y };
changed = true;
}
}
return changed ? next : prev;
});
}, [arrangeable]);
if (arrangeable.length < 2) return null;
const cur = pos ?? {};
@@ -1013,7 +1163,9 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
const onSave = () =>
saveLayout.mutate(
{ data: { positions: cur } },
// Saved-first, edits on top: the host replaces the whole map, so an absent device's
// placement survives only if we send it back.
{ data: { positions: { ...saved, ...cur } } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() });
@@ -1126,15 +1278,6 @@ const DisplayRow: FC<{
);
};
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
const apiErrorMessage = (err: unknown): string | undefined => {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
};
/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty
* now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and
* freed via Release. */
+11 -3
View File
@@ -10,9 +10,9 @@ import {
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiMonitorInfo } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QueryState } from "@/components/query-state";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
@@ -41,7 +41,11 @@ export const MonitorCard: FC = () => {
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
// environment is read-only here: offering controls that silently lose to the env would be worse
// than saying so.
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
//
// Requires the policy to have LOADED: while `/display/settings` is in flight (or has failed)
// `policy` is undefined, which is never equal to `pinned` — so the card used to announce an env
// pin that may not exist and go read-only on every slow load.
const envLocked = !!pinned && !!policy && policy.capture_monitor !== pinned;
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
@@ -85,7 +89,11 @@ export const MonitorCard: FC = () => {
className={cn(
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
(busy || locked) && "cursor-not-allowed opacity-60",
// `!onSelect` is a row that cannot be picked at all — a disabled head, or one of our own
// virtual displays. It was styled exactly like a selectable row and silently swallowed
// every click; it is listed so "why isn't my monitor here?" has an answer, so it has to
// LOOK unavailable too.
(busy || locked || !onSelect) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex flex-col gap-1">
+59 -19
View File
@@ -32,11 +32,18 @@ export const SessionGameCard: FC = () => {
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. Empty on a platform with no launch path (macOS), where the
// controls are shown disabled rather than hidden — "does nothing here" is information.
const enforced = q.data?.enforced ?? [];
const acts = (field: string) =>
enforced.length === 0 || enforced.includes(field);
// Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract
// says so outright ("Empty on a platform with no launch path (macOS), so the console can say so
// instead of offering a switch that does nothing"), and this card's own comment promises the
// controls are "shown disabled rather than hidden".
//
// The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the
// platform the flag exists for, every control stayed live: clicking one PUT the setting and
// toasted success for an axis the host would never act on. Absent (an older host that never
// sent the field) still means "assume it acts" — that is the compatible reading, and it is a
// different case from present-and-empty.
const enforced = q.data?.enforced;
const acts = (field: string) => !enforced || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
@@ -76,6 +83,7 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
group
>
<div className="flex flex-wrap gap-2">
<Choice
@@ -98,6 +106,7 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
group
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
@@ -130,9 +139,11 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
htmlFor="session-grace-seconds"
>
<div className="flex items-center gap-2">
<Input
id="session-grace-seconds"
type="number"
min={10}
max={86400}
@@ -162,7 +173,10 @@ export const SessionGameCard: FC = () => {
</Field>
)}
{enforced.length === 0 && (
{/* Present-and-empty is the "this build acts on none of it" signal; ABSENT
is an older host that never sent the field, where claiming inertness
would be a guess. Same distinction `acts()` makes above. */}
{enforced?.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
@@ -180,19 +194,45 @@ const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
always: () => m.session_game_end_always(),
};
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
/**
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
*
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all the grace input was
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
* separate on purpose (this card's axes are its own).
*/
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
htmlFor?: string;
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const body = (
<>
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</>
);
return group ? (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</fieldset>
) : (
<div className="space-y-3">{body}</div>
);
};
const Choice: FC<{
selected: boolean;
+48
View File
@@ -0,0 +1,48 @@
import { AlertTriangle } from "lucide-react";
import type { FC } from "react";
import { useGetLocalSummary } from "@/api/gen/host/host";
import { Card, CardContent } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* "Something else is already listening on these ports."
*
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, ) running on the same
* machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it,
* even though it is the single most common reason a punktfunk host looks installed and working but
* no client can reach it two servers fighting over the same ports, with whichever won the bind
* answering the client.
*
* Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome.
*/
export const ConflictsCard: FC = () => {
// Static per host boot (the host probes once at startup), so there is nothing to poll for.
const summary = useGetLocalSummary({ query: { staleTime: 5 * 60_000 } });
const conflicts = summary.data?.conflicts ?? [];
if (conflicts.length === 0) return null;
return (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3 p-card pt-card sm:pt-card">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
{m.host_conflicts_title()}
</p>
<p className="max-w-prose text-sm text-muted-foreground">
{m.host_conflicts_help()}
</p>
<ul className="flex flex-col gap-1">
{conflicts.map((c) => (
<li
key={c}
className="rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground"
>
{c}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
);
};
+78
View File
@@ -0,0 +1,78 @@
import { Check, Copy, Smartphone } from "lucide-react";
import { type FC, useState } from "react";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* "Get a device onto this host" the address to type, and the deep link that skips typing it.
*
* The console knew the host's identity and local address all along and never offered either in a
* form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on
* a couch. `punktfunk://connect/<unique_id>` is the shipped client grammar
* (clients/shared/deeplink-vectors.json the Rust, Swift and Kotlin parsers all test against it),
* so a client that is already installed opens straight onto this host.
*
* No QR code: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none.
* The link is short enough to send over any chat app, which is what people actually do.
*/
export const ConnectCard: FC<{ host: HostInfo }> = ({ host }) => {
const deepLink = `punktfunk://connect/${host.uniqueid}`;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.connect_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.connect_help()}
</p>
<CopyRow label={m.connect_address()} value={host.local_ip} />
<CopyRow label={m.connect_link()} value={deepLink} />
</CardContent>
</Card>
);
};
/** One labelled, monospaced value with a copy button — the point of the card. */
const CopyRow: FC<{ label: string; value: string }> = ({ label, value }) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
// Revert the affordance rather than leaving a permanent tick, which would stop reading as
// feedback the second time you press it.
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard denied (insecure origin, or the user said no) — the value is on screen and
// selectable, so there is nothing worth interrupting them about.
}
};
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs">
{value}
</code>
<Button
variant="outline"
size="icon"
aria-label={m.connect_copy()}
onClick={copy}
>
{copied ? (
<Check className="size-4 text-[var(--success)]" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
);
};
+8 -1
View File
@@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import {
getListGpusQueryKey,
@@ -10,6 +11,7 @@ import type { GpuState } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
@@ -20,15 +22,20 @@ import { m } from "@/paraglide/messages";
*/
export const GpuSection: FC = () => {
const qc = useQueryClient();
const gpus = useListGpus({ query: { refetchInterval: 5_000 } });
// GPU state only moves when a session starts or ends, which the event stream reports — so this
// is a slow safety net rather than a 5 s poll of a device enumeration.
const gpus = useListGpus({ query: { refetchInterval: 20_000 } });
const setPref = useSetGpuPreference();
// A refused GPU preference used to vanish: nothing read `setPref.error`, so the card simply
// stayed on the old selection as though the click had missed.
const apply = (mode: "auto" | "manual", gpuId?: string) =>
setPref.mutate(
{ data: { mode, gpu_id: gpuId ?? null } },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListGpusQueryKey() }),
onError: (e) => toast.error(apiErrorMessage(e) ?? m.gpu_apply_failed()),
},
);
+42 -13
View File
@@ -1,12 +1,14 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { UpdateStatus } from "@/api/gen/model";
import {
getGetUpdateStatusQueryKey,
useForceUpdateCheck,
useGetUpdateStatus,
} from "@/api/gen/update/update";
import type { UpdateStatus } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,6 +23,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { apiErrorMessage } from "@/lib/errors";
import { fmtDateTimeSecs } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
@@ -67,6 +71,14 @@ export const UpdateSection: FC = () => {
onSuccess: (fresh) => {
qc.setQueryData(getGetUpdateStatusQueryKey(), fresh);
},
// The host throttles repeat checks (429). Swallowing it made a second click within the
// window look like a dead button; say the check was skipped and why.
onError: (e) =>
toast.error(
e instanceof ApiError && e.status === 429
? m.update_apply_throttled()
: (apiErrorMessage(e) ?? m.update_error()),
),
});
return (
@@ -75,9 +87,8 @@ export const UpdateSection: FC = () => {
onCheck={checkNow}
checkBusy={check.isPending}
applying={applying}
onApplied={(target) =>
setApplying({ target, startedAt: Date.now() })
}
onApplied={(target) => setApplying({ target, startedAt: Date.now() })}
onGiveUp={() => setApplying(null)}
/>
);
};
@@ -88,11 +99,18 @@ export const UpdateCard: FC<{
checkBusy: boolean;
applying: { target: string; startedAt: number } | null;
onApplied: (target: string) => void;
}> = ({ state, onCheck, checkBusy, applying, onApplied }) => {
/** Leave the applying state after a timeout — otherwise the card waits forever. */
onGiveUp: () => void;
}> = ({ state, onCheck, checkBusy, applying, onApplied, onGiveUp }) => {
const s = state.data;
const inFlight = Boolean(applying) || Boolean(s?.job);
const timedOut =
applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS;
// Is the snapshot we are rendering still being refreshed? While the host is gone the query
// keeps its LAST payload — including a `job` that was in progress when it went away — so the
// timeout warning, gated on `!job`, could never fire in the one case it exists for. A failing
// poll means the job field describes a host we can no longer see.
const snapshotStale = Boolean(state.error);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@@ -156,6 +174,8 @@ export const UpdateCard: FC<{
status={s}
reconnecting={Boolean(state.error)}
timedOut={timedOut}
snapshotStale={snapshotStale}
onGiveUp={onGiveUp}
/>
) : s.available ? (
s.apply === "full" || s.apply === "staged" ? (
@@ -214,7 +234,7 @@ export const UpdateCard: FC<{
{s.last_checked_unix != null && (
<span className="text-xs text-muted-foreground">
{m.update_last_checked()}{" "}
{new Date(s.last_checked_unix * 1000).toLocaleString()}
{fmtDateTimeSecs(s.last_checked_unix)}
</span>
)}
</div>
@@ -356,7 +376,10 @@ const ApplyProgress: FC<{
status: UpdateStatus;
reconnecting: boolean;
timedOut: boolean;
}> = ({ status, reconnecting, timedOut }) => {
/** The rendered snapshot can no longer be refreshed — its `job` may describe a vanished host. */
snapshotStale: boolean;
onGiveUp: () => void;
}> = ({ status, reconnecting, timedOut, snapshotStale, onGiveUp }) => {
const job = status.job;
const pct =
job?.total_bytes && job.total_bytes > 0
@@ -396,12 +419,18 @@ const ApplyProgress: FC<{
/>
</div>
)}
{/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out"
the warning is for the host being GONE longer than a restart explains. */}
{timedOut && !job && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
{m.update_apply_timeout()}
</p>
{/* A job we can still SEE progressing (e.g. the Deck's tens-of-minutes source rebuild) is
not "timed out" the warning is for the host being GONE longer than a restart
explains. A stale snapshot's job does not count as seeing one. */}
{timedOut && (!job || snapshotStale) && (
<div className="space-y-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p>{m.update_apply_timeout()}</p>
{/* Without this the card sits in "applying" forever and the operator cannot even
re-check the state was only ever cleared by a status that never arrives. */}
<Button variant="outline" size="sm" onClick={onGiveUp}>
{m.update_apply_give_up()}
</Button>
</div>
)}
</div>
);
+2
View File
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { ConflictsCard } from "./ConflictsCard";
import { GpuSection } from "./GpuCard";
import { UpdateSection } from "./UpdateCard";
import { HostView } from "./view";
@@ -14,6 +15,7 @@ export const SectionHost: FC = () => {
<HostView
host={host}
compositors={compositors}
conflicts={<ConflictsCard />}
gpu={<GpuSection />}
update={<UpdateSection />}
/>
+8 -1
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { ConnectCard } from "./ConnectCard";
export const HostView: FC<{
host: Loadable<HostInfo>;
@@ -16,13 +17,19 @@ export const HostView: FC<{
gpu?: ReactNode;
/** The update-check card (a self-contained container — see `UpdateCard.tsx`). */
update?: ReactNode;
}> = ({ host, compositors, gpu, update }) => {
/** Warning about other Moonlight-compatible servers on this machine renders nothing when
* there are none (see `ConflictsCard.tsx`). Sits at the top: it explains "nothing can connect". */
conflicts?: ReactNode;
}> = ({ host, compositors, gpu, update, conflicts }) => {
const h = host.data;
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
{conflicts}
{h && <ConnectCard host={h} />}
<QueryState
isLoading={host.isLoading}
error={host.error}
+13 -1
View File
@@ -40,7 +40,12 @@ export const GameCard: FC<GameCardProps> = ({
onDelete,
deleting,
}) => {
const isCustom = game.store === "custom";
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
// buttons produced a failure the card never surfaced. Provider-owned entries are attributed
// instead.
const isCustom = game.store === "custom" && !game.provider;
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
@@ -79,6 +84,13 @@ export const GameCard: FC<GameCardProps> = ({
{game.platform}
</Badge>
)}
{/* Who owns this entry, when it isn't the operator the reason the edit/delete
buttons are absent here and present on the card next to it. */}
{game.provider && (
<Badge variant="outline" className="bg-background/80 backdrop-blur">
{m.library_owned_by({ provider: game.provider })}
</Badge>
)}
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
+36 -4
View File
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
import { customId } from "./helpers";
@@ -133,10 +134,18 @@ export const GameFormSection: FC<{
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
// A rejected save must not close the form and must not look like a success. It used to do both:
// nothing read `create.error`/`update.error`, and the un-caught `mutateAsync` rejection meant
// the entry silently didn't save while the dialog disappeared — taking the operator's typing
// with it.
const onSubmit = async (data: CustomInput) => {
if (target === "new") await create.mutateAsync({ data }).then(invalidate);
else
await update.mutateAsync({ id: customId(target), data }).then(invalidate);
try {
if (target === "new") await create.mutateAsync({ data });
else await update.mutateAsync({ id: customId(target), data });
} catch {
return; // the message is rendered from the mutation's own error state below
}
invalidate();
onClose();
};
@@ -147,6 +156,7 @@ export const GameFormSection: FC<{
onSubmit={onSubmit}
onCancel={onClose}
isSaving={create.isPending || update.isPending}
error={apiErrorMessage(create.error ?? update.error)}
/>
);
};
@@ -187,7 +197,9 @@ export const GameForm: FC<{
onSubmit: (data: CustomInput) => void;
onCancel: () => void;
isSaving: boolean;
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
/** The host's refusal, if the last save failed — shown next to the button that caused it. */
error?: string;
}> = ({ initial, mode, onSubmit, onCancel, isSaving, error }) => {
const [form, setForm] = useState<FormState>(initial);
const set = (key: keyof FormState) => (value: string) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -331,6 +343,26 @@ export const GameForm: FC<{
help={m.library_field_tags_help()}
/>
</fieldset>
{/* Data-loss warning, not a nicety.
`PUT /library/custom/{id}` REPLACES the entry (host: library/custom.rs
`update_custom` assigns `slot.prep = input.prep; slot.detect = input.detect`),
but `GET /library` returns a `GameEntry`, which carries neither field. So the
console cannot round-trip them anything configured outside this form is dropped
by a save it did not intend to touch. The real fix is host-side (expose `detect`
and `prep` on the read model); until then, say so before the operator finds out. */}
{mode === "edit" && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
{m.library_edit_overwrites()}
</p>
)}
{error && (
<p
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</p>
)}
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{mode === "edit" ? m.library_save() : m.library_create()}
+38 -8
View File
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { motion, stagger } from "motion/react";
import type { FC } from "react";
import { type FC, useEffect, useMemo } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
@@ -9,6 +10,7 @@ import {
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Card, CardContent } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { GameCard } from "./GameCard";
@@ -19,23 +21,51 @@ import { customId } from "./helpers";
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
onEdit,
}) => {
export const LibraryGridSection: FC<{
onEdit: (entry: GameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: GameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
if (all) onEntries?.(all);
}, [all, onEntries]);
// Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
// list for the grid, and a second parameterised query would just be a second cache entry of the
// same data going stale independently.
const filtered = useMemo(
() =>
providerFilter
? {
...library,
data: all?.filter((e) => e.provider === providerFilter),
}
: library,
[library, all, providerFilter],
);
const remove = useDeleteCustomGame();
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await remove
.mutateAsync({ id: customId(entry) })
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
try {
await remove.mutateAsync({ id: customId(entry) });
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_delete_failed());
return;
}
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
return (
<LibraryGrid
library={library}
library={filtered}
onEdit={onEdit}
onDelete={onDelete}
// The custom id whose delete is in flight (if any), so only that card's button disables.
+104
View File
@@ -0,0 +1,104 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useDeleteProviderEntries,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
/**
* Provider-owned entries: who put them there, and how to get rid of them.
*
* A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or
* delete individually the host answers 409 and points at the provider's own reconcile. Which is
* correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the
* library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the
* documented clean-uninstall path and nothing called it.
*
* Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome.
*/
export const ProvidersCard: FC<{
entries: GameEntry[];
/** The provider currently filtered to, or null for "everything". */
active: string | null;
onFilter: (provider: string | null) => void;
}> = ({ entries, active, onFilter }) => {
const qc = useQueryClient();
const purge = useDeleteProviderEntries();
// Count per provider, in first-seen order — the list is small and operator-facing.
const counts = new Map<string, number>();
for (const e of entries) {
if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1);
}
if (counts.size === 0) return null;
const onPurge = async (provider: string, count: number) => {
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
try {
await purge.mutateAsync({ provider });
// The host emits `library.changed`, but don't wait for the round trip to redraw.
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
if (active === provider) onFilter(null);
toast.success(m.library_provider_purged({ provider }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
}
};
return (
<Card>
<CardHeader>
<CardTitle>{m.library_providers_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_providers_help()}
</p>
<div className="flex flex-col gap-2">
{[...counts.entries()].map(([provider, count]) => (
<div
key={provider}
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
>
<span className="font-medium">{provider}</span>
<Badge variant="secondary">
{m.library_provider_count({ count })}
</Badge>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant={active === provider ? "default" : "outline"}
aria-pressed={active === provider}
onClick={() =>
onFilter(active === provider ? null : provider)
}
>
{active === provider
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
disabled={purge.isPending}
aria-label={m.library_provider_purge()}
onClick={() => onPurge(provider, count)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
+17 -1
View File
@@ -1,11 +1,13 @@
import Section from "@unom/ui/section";
import { Plus } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
import { ProvidersCard } from "./Providers";
import { SourceTogglesSection } from "./SourceToggles";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
@@ -16,6 +18,10 @@ export const SectionLibrary: FC = () => {
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
// by the target re-seeds its fields when switching add → edit (or between entries).
const [target, setTarget] = useState<FormTarget | null>(null);
// The full list, lifted from the grid so the providers card can count owners without a second
// copy of the same query, plus which provider (if any) the grid is filtered to.
const [entries, setEntries] = useState<GameEntry[]>([]);
const [providerFilter, setProviderFilter] = useState<string | null>(null);
return (
<Section maxWidth={false}>
@@ -40,7 +46,17 @@ export const SectionLibrary: FC = () => {
<SourceTogglesSection />
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
<ProvidersCard
entries={entries}
active={providerFilter}
onFilter={setProviderFilter}
/>
<LibraryGridSection
onEdit={(entry) => setTarget(entry)}
providerFilter={providerFilter}
onEntries={setEntries}
/>
</div>
</Section>
);
+101 -8
View File
@@ -49,6 +49,8 @@ export const LogsSection: FC = () => {
const [follow, setFollow] = useState(true);
const [dropped, setDropped] = useState(false);
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
// Set while a poll has failed and we have not yet re-read the ring from the start.
const [resync, setResync] = useState(false);
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
// on hydration. Until then the share button is simply absent.
@@ -58,22 +60,58 @@ export const LogsSection: FC = () => {
const query = useLogsGet(
{ after: cursor > 0 ? cursor : undefined },
{ query: { refetchInterval: follow ? 2_000 : false } },
{
query: {
refetchInterval: follow ? 2_000 : false,
// Pausing must actually pause. Stopping only the interval left React Query's default
// focus/reconnect refetches landing, and the append effect consumed them
// unconditionally — so tabbing away and back evicted the lines the operator had
// paused on, from behind the pause button.
refetchOnWindowFocus: follow,
refetchOnReconnect: follow,
},
},
);
// Resync after the host goes away and comes back.
//
// The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it
// got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently
// EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with
// no error, no dropped badge and no way back short of a full reload. The console's own update
// flow restarts the host, so this was reachable from two clicks away.
//
// A restart always breaks the poll first, so a failed query is the trigger: on the next success
// we re-read from the start of the ring once and let the effect below decide whether the
// sequence actually regressed.
const failed = query.isError;
useEffect(() => {
if (failed) setResync(true);
}, [failed]);
useEffect(() => {
if (resync && cursor !== 0) setCursor(0);
}, [resync, cursor]);
const data = query.data;
useEffect(() => {
if (!data || data.entries.length === 0) return;
setEntries((prev) => {
// Only append entries newer than what we already hold — dedup by the monotonic `seq`.
// Guards a double-invoked mount effect (React StrictMode, or `data` warm in cache) from
// appending the same page twice (duplicate rows + duplicate React keys).
const lastSeq = prev.at(-1)?.seq ?? -1;
// A page whose newest entry is OLDER than what we already hold can only mean the host's
// sequence restarted underneath us — the buffer describes a host that no longer exists,
// so replace it wholesale rather than filtering every new line away as "already seen".
const newest = data.entries.at(-1)?.seq ?? -1;
if (newest < lastSeq) return data.entries.slice(-KEEP);
// Otherwise append only what's newer — dedup by the monotonic `seq`. Guards a
// double-invoked mount effect (React StrictMode, or `data` warm in cache) from appending
// the same page twice (duplicate rows + duplicate React keys), and makes the post-resync
// re-read from 0 a no-op when the host did NOT restart.
const fresh = data.entries.filter((e) => e.seq > lastSeq);
return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev;
});
setDropped((d) => d || data.dropped);
setCursor(data.next);
setResync(false);
}, [data]);
// The card hands back the entries its filters currently match, so an export carries exactly what
@@ -100,6 +138,9 @@ export const LogsSection: FC = () => {
}}
shareMode={shareMode}
dropped={dropped}
error={query.error}
isLoading={query.isLoading}
onRetry={() => query.refetch()}
/>
);
};
@@ -118,6 +159,10 @@ export const LogsCard: FC<{
onShare: (shown: LogEntry[]) => void;
shareMode: ShareMode | null;
dropped: boolean;
/** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */
error?: unknown;
isLoading?: boolean;
onRetry?: () => void;
}> = ({
entries,
follow,
@@ -127,6 +172,9 @@ export const LogsCard: FC<{
onShare,
shareMode,
dropped,
error,
isLoading,
onRetry,
}) => {
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
const [search, setSearch] = useState("");
@@ -146,16 +194,32 @@ export const LogsCard: FC<{
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
// Keep the tail in view while following (entries are append-only, so length is a good signal).
// Keep the tail in view while following.
//
// Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
// so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then
// stopped re-running and follow-mode quietly stopped following — exactly when the log is busy
// enough to need it. The newest seq keeps changing for as long as lines arrive.
const newestVisible = visible.at(-1)?.seq ?? -1;
// NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and
// offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived.
// Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode
// quietly stops following. The same warning was here before, on `visible.length`.
useEffect(() => {
if (!follow) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [follow, visible.length]);
}, [follow, newestVisible]);
return (
<Card>
<CardContent className="flex flex-col gap-3 pt-6">
{/* This card has no CardHeader, so it has to put the top padding back itself and it
must do so at BOTH breakpoints. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and
tailwind-merge only resolves conflicts within the same variant: a bare `pt-6` cancels
`pt-0` but leaves `sm:pt-0` standing, so the padding was 24px on a phone and 0 on a
desktop, with the filter row touching the card's edge. (Same trap the `p-0` note in
components/ui/card.tsx describes, in the other direction.) */}
<CardContent className="flex flex-col gap-3 pt-4 sm:pt-6">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
@@ -222,12 +286,41 @@ export const LogsCard: FC<{
</div>
</div>
{/* A failing poll while lines are already on screen keeps them there during a host
restart the last lines before it went away are the interesting ones but says so,
instead of letting a frozen view read as a quiet host. */}
{error != null && entries.length > 0 && (
<p
role="status"
className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
{m.logs_stalled()}
</p>
)}
<div
ref={listRef}
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
>
{visible.length === 0 ? (
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
// An empty list has three quite different causes and used to render one sentence
// for all of them: the host is quiet, the request failed, or it hasn't answered yet.
<div className="p-2">
{error ? (
<div className="space-y-2 font-sans">
<p className="text-destructive">{m.common_error()}</p>
{onRetry && (
<Button size="sm" variant="outline" onClick={onRetry}>
{m.common_retry()}
</Button>
)}
</div>
) : (
<p className="text-muted-foreground">
{isLoading ? m.common_loading() : m.logs_empty()}
</p>
)}
</div>
) : (
visible.map((e) => (
<div key={e.seq} className="whitespace-pre-wrap break-words">
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { Info, KeyRound } from "lucide-react";
import { type FC, useState } from "react";
import { type FC, useEffect, useRef, useState } from "react";
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import {
getGetPairingStatusQueryKey,
@@ -22,16 +23,37 @@ export const MoonlightPairingSection: FC = () => {
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const onSubmit = () =>
// Clear the previous attempt's outcome when a NEW pairing knock arrives.
//
// The mutation's success flag outlives the form — the section never unmounts, only the inner
// <form> is conditional — so the green "PIN sent" note was still on screen above an empty PIN
// box the next time Moonlight asked. Resetting inside `onSubmit` (the first attempt at this)
// does nothing: `mutate` moves the status to pending in the same update, so `isSuccess` was
// already about to go false. The transition that matters is `pin_pending` going false → true.
const pending = pairing.data?.pin_pending ?? false;
const wasPending = useRef(pending);
useEffect(() => {
if (pending && !wasPending.current) {
submit.reset();
setPin("");
}
wasPending.current = pending;
}, [pending, submit.reset]);
const onSubmit = () => {
submit.mutate(
{ data: { pin } },
{
onSuccess: () => {
setPin("");
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
// The success message tells the operator to check the paired list, so refresh it —
// both planes, since this card's count spans them.
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() });
},
},
);
};
return (
<MoonlightPairing
+4 -1
View File
@@ -24,7 +24,10 @@ import { m } from "@/paraglide/messages";
*/
export const PendingDevicesSection: FC = () => {
const qc = useQueryClient();
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
// the rows carry an age that should not visibly lag.
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
+17 -4
View File
@@ -34,19 +34,32 @@ export const SectionPlugin: FC = () => {
const { data: installed } = useInstalledPlugins();
const provenance = installed?.find((p) => p.plugin_id === pluginId);
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
// offline card (the manual Retry re-probes).
// Liveness: a 200 from /__health means the plugin is up.
//
// Two subtleties, both learned the hard way:
//
// - A 200 is not enough. `fetch` follows redirects, so an expired session — where the gate
// answers 302 → /login → 200 HTML — looked exactly like a healthy plugin, and the console
// rendered its own login page inside the plugin's iframe. `redirect: "manual"` makes that
// an opaque response we can reject instead.
// - One failure must not be terminal. The runner is restarted at the end of every successful
// install, so a single missed probe is routine; giving up on the first one threw away
// whatever the operator had open in another plugin. Retry a few times, and keep probing on a
// slower beat while down so it recovers on its own.
const health = useQuery({
queryKey: ["plugin-health", pluginId],
queryFn: async () => {
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
credentials: "same-origin",
redirect: "manual",
});
// `type === "opaqueredirect"` is the gate bouncing us to /login, not the plugin answering.
if (r.type === "opaqueredirect") throw new Error("session expired");
if (!r.ok) throw new Error(`health ${r.status}`);
return true;
},
retry: false,
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
retry: 2,
refetchInterval: (q) => (q.state.status === "error" ? 5_000 : 20_000),
});
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
+11 -1
View File
@@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Circle, Square } from "lucide-react";
import type { FC } from "react";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
@@ -13,6 +14,7 @@ import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { fmtDuration, kindLabel, Stat } from "./helpers";
@@ -29,13 +31,21 @@ export const CaptureControlSection: FC = () => {
const refreshStatus = () =>
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
const onStart = () => start.mutate(undefined, { onSuccess: refreshStatus });
// Both paths report failure. A failed STOP is the one that matters: it is "stop & save", so
// swallowing the error let a capture the operator had been recording for minutes disappear with
// no recording written and nothing on screen to say so.
const onStart = () =>
start.mutate(undefined, {
onSuccess: refreshStatus,
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_start_failed()),
});
const onStop = () =>
stop.mutate(undefined, {
onSuccess: () => {
refreshStatus();
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
},
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_stop_failed()),
});
return (
+33 -3
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import { type FC, useMemo } from "react";
import { ApiError } from "@/api/fetcher";
import type { Capture } from "@/api/gen/model/capture";
import {
@@ -9,7 +9,7 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { LatencyChart, ThroughputChart } from "./charts";
import { HealthChart, LatencyChart, ThroughputChart } from "./charts";
import { ChartBlock } from "./helpers";
/**
@@ -27,9 +27,27 @@ export const LiveSection: FC = () => {
return <LiveCard live={live} />;
};
/**
* How many samples the live charts plot.
*
* The live endpoint returns the capture SO FAR, which grows without bound a capture left running
* over an evening is tens of thousands of samples, re-serialised and re-plotted every 2 s. The tail
* is also the only part anyone watches live (the full series is what the saved recording is for),
* so plot a bounded window and leave the rest to the detail view.
*/
const LIVE_WINDOW = 600;
/** Live graphs while a capture is armed: latency stack + throughput. */
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
const samples = live.data?.samples ?? [];
const all = live.data?.samples;
// Memoised on the array identity: React Query keeps it stable when a poll changed nothing, so
// an unchanged poll costs no re-slice and — because `samples` keeps its identity — no chart
// rebuild either (the charts memoise on exactly this).
const samples = useMemo(
() =>
all && all.length > LIVE_WINDOW ? all.slice(-LIVE_WINDOW) : (all ?? []),
[all],
);
// A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as
// "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting".
const error =
@@ -58,6 +76,18 @@ export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
<ChartBlock title={m.stats_throughput_title()}>
<ThroughputChart samples={samples} />
</ChartBlock>
{/* Loss/recovery was only ever visible AFTER stopping and reopening the
saved recording which is backwards: dropped frames and FEC recovery
are what you watch a live capture FOR. The `kind` note keeps the
GameStream caveat (only `frames` is instrumented there). */}
<ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} kind={live.data?.meta?.kind} />
</ChartBlock>
{(live.data?.samples?.length ?? 0) > LIVE_WINDOW && (
<p className="text-xs text-muted-foreground">
{m.stats_live_window({ count: LIVE_WINDOW })}
</p>
)}
</>
)}
</QueryState>

Some files were not shown because too many files have changed in this diff Show More