Commit Graph
52 Commits
Author SHA1 Message Date
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 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 Fable 5 239c69fd71 feat(web,docs): Linux one-click in the card — opt-in hint, staged and nothing-newer outcomes
audit / bun-audit (sdk) (push) Successful in 18s
audit / bun-audit (web) (push) Successful in 18s
audit / docs-site-audit (push) Successful in 23s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (plugin-kit) (push) Successful in 1m14s
arch / build-publish (push) Failing after 1m17s
ci / rust-arm64 (push) Failing after 44s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
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 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 2m18s
deb / build-publish-host (push) Failing after 2m15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
deb / build-publish (push) Failing after 3m43s
audit / license-gate (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m49s
ci / rust (push) Failing after 3m6s
docker / deploy-docs (push) Successful in 55s
windows-host / package (push) Failing after 6m27s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Successful in 8m21s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
flatpak / build-publish (push) Successful in 4m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m19s
release / apple (push) Canceled after 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The apply panel serves staged kinds too; notify mode shows the group-join command when
the helper is installed but not yet enabled; outcomes render 'staged — reboot to
finish' and 'your package source had nothing newer yet' distinctly. Docs: the Linux
opt-in section (why it's a group, what the grant bounds, the pacman stance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb1f93d90e feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console
password is re-verified per apply (login throttle shared, stripped before forwarding) so
a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating
request (login CSRF included). The card's apply flow: confirm dialog → live-session
force escalation → download/verify/restart progress rendered from the last snapshot
while polls fail (the host and, on Windows, this very server restart mid-flow) →
durable success/failure from last_result. Docs: the one-click section + kill switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 b275e6d34c feat(web,docs): the Updates card — version, channel, install kind, and the exact update command
android / android (push) Canceled after 1m54s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m15s
arch / build-publish (push) Canceled after 1m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
ci / rust (push) Canceled after 59s
ci / rust-arm64 (push) Canceled after 2m31s
ci / web (push) Canceled after 0s
deb / build-publish (push) Canceled after 18s
deb / build-publish-host (push) Canceled after 19s
deb / build-publish-client-arm64 (push) Canceled after 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m28s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
decky / build-publish (push) Successful in 50s
Notify-only (U0): polls /update/status (which keeps the host's manifest cache
warm), Check-now with the 30s limit surfaced, release-notes link, stale-feed and
check-disabled states, copyable per-install-kind command. en+de strings; docs-site
'Updating the Host' page wired into the install section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 77517bbe21 style(web): let biome format the message catalogues and the Logs story
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
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 27s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-host (push) Successful in 4m29s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Successful in 6m6s
arch / build-publish (push) Successful in 9m36s
windows-host / package (push) Successful in 20m26s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m39s
Cosmetic only, and kept out of the feature commit it rode in on so neither
has to be read through the other. `biome check --write` reindents both
message catalogues from two spaces to tabs (877 lines each, no key, order,
or value touched — verified by comparing the parsed objects) and rewraps the
Logs story fixtures onto one argument per line with trailing commas.

Worth knowing for next time: the catalogues are also written by inlang, which
formats with two spaces, so an edit made through that tooling will pull them
back. Nothing depends on either shape — `bun run codegen` compiles 440
messages for both locales either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:28:37 +02:00
enricobuehlerandClaude Fable 5 8d6241efae feat(web): the logs page can hand a log off — as a file, or to the share sheet
The Logs page could only be read in place. Getting a host log into a bug
report meant selecting a screenful of monospace text and hoping the scroll
container gave up the rest.

Two controls in the toolbar now do it properly. Download writes a .log file
named for the moment it was taken; the second button hands the same text to
the OS share sheet where there is one (phones, iPads), and copies it to the
clipboard everywhere else — which is why it is probed at runtime rather than
guessed, and why the button is absent on the one combination where neither
exists (plain HTTP, no Web Share).

Both export what the filters currently match, not the rendered tail: the
1000-row cap is a DOM budget and has nothing to say about how long a file
may be. Lines carry the full date and UTC offset, since a bare wall-clock
time stops meaning anything the moment the file leaves the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:07:15 +02:00
enricobuehlerandClaude Fable 5 67ab64ed06 feat(web): the Host page names — and marks — the operating system
The Identity card gains an "Operating system" row: the pretty name as text,
the raw identity chain as its tooltip, and a leading OsIcon resolved by the
same most-specific-first walk as the clients. lucide deliberately ships no
brand marks, so os-icon.tsx vendors the ten as inline currentColor SVGs
(the brand-mark precedent, the plugin-registry pattern), falling back to a
generic monitor for a chain nothing recognizes. Row grows optional icon/title
props rather than a fork; stories cover Bazzite, Windows and the
unknown-distro degradation; labels land in both message files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00
enricobuehler 50531c8e9e fix(host/display): a screen picker that cannot stream a screen now says so instead of saving
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
release / apple (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 3m56s
The Windows console's "Streamed screen" picker SAVED and then did nothing. `capture_monitor` is
platform-neutral, so the PUT persisted; every consumer of it is Linux-gated, so a virtual display
was still created on connect. The operator got a control that acknowledged the click and changed
nothing — the worst of the three possible behaviours.

The root cause is not a missing gate, it is a missing BACKEND. Per-monitor capture is Linux/portal
only: `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]` because `pf-capture` has no
Windows entry point that can capture an arbitrary head. Its sole Windows entry point is
`open_idd_push`, a frame channel pushed by our OWN IddCx virtual display; DXGI Desktop Duplication
was deliberately REMOVED (`windows/dxgi.rs` keeps the GPU-preference hook only to stop DXGI
reparenting the virtual display off the pinned adapter, and says so). So there is nothing for a pin
to aim at, and exposing the picker got ahead of that.

Decision of record (user, this session): mark it unsupported now rather than build the Windows
backend here. A Windows mirror backend is a real feature gap and a project of its own — it needs a
duplication capturer plus everything the IDD-push path carries today (cursor sidechannel, 444,
10-bit, PyroWave, HDR) re-plumbed through it — not a follow-on to an enumeration commit.

Three places, so the answer is consistent wherever it is asked:

* `MonitorsResponse.pin_supported` — a CAPABILITY, reported by the build that would have to honor
  the pin rather than sniffed from the OS client-side. When a Windows mirror backend lands this
  flips and the console needs no change. `pinned` stays `None` off-Linux deliberately, and now says
  why: it is what the console highlights as "sessions stream this", and highlighting a head nothing
  will capture is the same lie in a different place.
* `enforced` drops `capture_monitor` off Linux. That list is exactly the "which controls are live vs.
  coming soon" contract, and claiming this one unconditionally is what let the picker ship enabled.
* The PUT drops a non-Linux `capture_monitor` instead of storing it, and logs that it did. COERCED,
  not rejected with a 400: this PUT is WHOLE-OBJECT, so a host that already stored a pin would have
  every later settings save rejected over a field the operator cannot see — taking the other axes
  down with it. This way such a policy self-heals on the next write.

Console: the picker renders read-only with an explanation, reusing the `envLocked` shape rather than
inventing a second one (`locked = envLocked || !pinSupported`). Managed heads were ALREADY excluded
from selection here (`mon.enabled && !mon.managed`), which is the "grey out, never filter blindly"
rule working as intended — the heads stay listed and explicable. `pin_supported` defaults to TRUE
when absent so an older host, which only ever shipped this picker where it worked, is not
retroactively locked out.

⚠️ `api/openapi.json` is hand-patched — punktfunk-host does not build on macOS, so the spec could not
be regenerated from the binary. The shape was verified by running the real generator over it: orval
emits `pin_supported: boolean` (required, no `?`) on `MonitorsResponse`.

Verified: `bun run codegen` (orval + paraglide + i18n parity: 434 messages, en + de), `bun run build`,
`bun run lint` (tsc --noEmit) all clean. Rust verified separately on .173 — see the next commit's
note if that lands, since neither xcheck target covers punktfunk-host.
2026-07-29 00:18:27 +02:00
enricobuehler 0868b6a364 fix(vdisplay): compositor availability follows the live session, not the env
GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter
session — and, on the same request, "Default", because the two columns had
different sources. available() asked each backend, and those probes read
the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for
KWin's registry handshake, SWAYSOCK for sway) — env a host started outside
the session (systemd --user, a TTY, ssh) never inherited. It is only
retargeted at the live session on the connect path, so the answer also
flipped depending on whether anyone had connected yet.

Both columns now come from the same /proc scan detect() already used: the
live session's compositor is usable by definition, as is an explicit
operator pin, and the per-backend probe stays as the fallback for backends
that are not the live session (gamescope, which spawns its own). A live
KWin without the zkde_screencast grant now surfaces as available and fails
at create with that probe's precise message, which beats "no usable
compositor" on a box visibly running KDE.

Mutter's env sniff stays deliberately narrow — one var, not three.
XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written
per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION
alongside would resurrect the bug that scrub exists to prevent, where a
stale value after a gnome-shell crash routes the next client into a dead
session.

Non-Linux hosts now report no compositors at all rather than five Linux
backends flagged unavailable with no default — on Windows the pf-vdisplay
driver is the only backend and vdisplay::open ignores the argument, so the
old list read as broken detection instead of "not applicable here". The
console says so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636)
2026-07-28 17:01:59 +02:00
enricobuehler 3b11288c97 fix(web): unsaved custom display settings are visible and recoverable
Everything else on the Displays page auto-applies — a preset click, the
game-session choice, the experimental toggles — but the Custom block does
not, and its Save button sat at the bottom of a block taller than most
viewports. People edited, never scrolled far enough to find it, navigated
away, and silently lost the lot.

The draft is now compared against the last-seeded server value, and that
one boolean drives the whole affordance: a badge in the card header
(visible without scrolling), an amber ring plus a title on the block, and
a sticky action bar pinned to the viewport for as long as any part of the
block is on screen. The bar states which of the two states you are in
rather than leaving it implied, Save disables when there is nothing to
save, and Discard changes puts the stored policy back.

Two ways edits could still vanish are closed: a preset click now confirms
before overwriting pending edits, and a reload/close warns. Re-picking
Custom while already on Custom is a no-op instead of re-seeding, which
would otherwise fill in defaults for unset fields and report "unsaved
changes" for a click that changed nothing.

Adds a `warning` badge variant + --warning token for the pending state —
attention, not failure, and distinct from the destructive red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c4318609c0da5ae1313081c5e488f685504f70d1)
2026-07-28 17:01:59 +02:00
enricobuehlerandClaude Opus 5 d461d889c3 feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.

Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.

`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.

Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.

Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00
enricobuehler 35b6c835fd docs: on a gamescope session the display outranks the end-game setting
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m19s
decky / build-publish (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
ci / bench (push) Successful in 5m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 9m2s
windows-host / package (push) Successful in 20m22s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 14m46s
docker / build-push-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m36s
ci / rust (push) Successful in 27m3s
apple / swift (push) Successful in 6m5s
apple / screenshots (push) Successful in 25m23s
deb / build-publish-host (push) Successful in 16m6s
deb / build-publish-client-arm64 (push) Successful in 11m21s
deb / build-publish (push) Successful in 11m28s
On-glass on .41: with `game_on_session_end: keep` — "leave it running, nothing
is ever closed" — a deliberate stop ended the game, and a drop ended it ~14 s
later. Both times the LEASE did the right thing (no `ending the launched game`
line); the game died because the virtual display was torn down, and a nested
launch runs inside that gamescope.

So on a dedicated gamescope session the game's lifetime IS the display's, and
keep-alive decides it — not this setting:

  * a deliberate stop skips the linger by design, so the game goes at once,
  * a drop lingers the keep-alive window, then takes the game with it,
  * only keep-alive Forever actually keeps it — and `always` still ends it at
    the reconnect window, which is the documented precedence and was verified
    (a `forever`-pinned display released at grace expiry, 60.006 s).

That makes the console's promise untrue on the most common Linux setup, so the
card now says so for every option rather than only warning about `always`, and
the docs get the three cases as a table. Worded so a non-gamescope host reads it
and moves on — a desktop-session launch is an ordinary process and none of it
applies.

Not changing behavior here: making `keep` pin the display would pit it against
"a stop must not leave a ghost display", and which wins is a product call rather
than something to decide silently mid-test.

web: codegen + tsc + biome clean, en/de complete. docs-site: build + tsc clean.
2026-07-27 01:43:40 +02:00
enricobuehlerandClaude Opus 5 1ee06defa6 feat(library): descriptive metadata on every entry — platform, year, genres, and friends
apple / swift (push) Successful in 5m38s
windows-host / package (push) Successful in 19m0s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m32s
ci / rust (push) Failing after 12m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
android / android (push) Successful in 17m49s
decky / build-publish (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
arch / build-publish (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
deb / build-publish (push) Successful in 9m9s
deb / build-publish-client-arm64 (push) Successful in 7m26s
deb / build-publish-host (push) Successful in 9m46s
flatpak / build-publish (push) Failing after 8m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m56s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 25m51s
Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.

- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
  platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
  ?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
  field, since update replaces the entry), the poster tile a platform
  badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
  spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:00:30 +02:00
enricobuehler 6a2c127ce9 feat(mgmt/web): show the running game, and who decides when it ends
The host knows what it launched and, after a disconnect, that it is counting
down to closing it. None of that was visible: the console showed a stream with
no game, and a game on its way to being closed was something you found out about
afterwards.

The Dashboard gains a running-game card above the session card — box art matched
against the catalog it already fetches, so no new endpoint and no request on the
2 s status poll. "End now" means the two different things the row's state
implies: a live game ends by stopping its session (what then happens to the game
follows the policy — stopping a session is not licence to close a game), while
one already waiting out its reconnect window has no session left to stop and is
ended directly.

The settings card sits next to the display keep-alive policy because that is the
same question one step out: keep-alive decides how long a *display* outlives a
disconnect, this decides whether the *game* does. The copy says plainly what
`always` costs, that a drop is not someone pressing Stop, and that a display kept
forever is unaffected either way — the precedence rule that would otherwise
surprise someone. Where a build enforces nothing (macOS, no launch path) the
controls are shown disabled rather than hidden: "does nothing here" is
information.

Also fixes the story fixtures, which had gone stale against the `games[]` field
Phase 1 added, and adds a story for the state the card exists for — a game whose
client walked away.

web: build + tsc clean, biome-formatted; en/de messages complete.
2026-07-26 18:28:13 +02:00
enricobuehlerandClaude Fable 5 6c2c8b6eab feat(mgmt/web): surface the host.env encoder pin so a conflicting GPU choice isn't invisible
listGpus gains encoder_pin (PUNKTFUNK_ENCODER when it actually pins something —
unset/auto stay null), and the console's GPU card shows it: a muted note when
the pin is compatible, an amber warning naming the pinned vendor and the next
session's GPU when it contradicts the selection (the host overrides such a pin
at session open — without this field the selection just looked broken). Docs
updated to say the adapter wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:53:19 +02:00
enricobuehlerandClaude Fable 5 833f3348a0 feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.

Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.

Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
  scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
  own framework: it listed as installed and would have been imported as a
  unit. Both now key off the plugins dir's top-level dependencies, with an
  emptied dependency list meaning 'nothing installed' rather than falling
  back to the naming convention
- the store must not pass new flags to the runner: the scripting package
  ships separately and an older one reads an unknown flag's value as a
  package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
  as Ok with an empty body — handled as an error it made every refresh
  after the first verify a signature over zero bytes and sit stale

Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00
enricobuehlerandClaude Fable 5 c2bba13405 feat(host/web): per-scanner library toggles in the console
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Failing after 24s
ci / bench (push) Successful in 5m31s
deb / build-publish (push) Successful in 9m21s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
android / android (push) Successful in 19m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 9m44s
arch / build-publish (push) Successful in 18m17s
ci / rust (push) Successful in 19m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
windows-host / package (push) Successful in 17m47s
docker / deploy-docs (push) Successful in 10s
Every installed-store scanner (Steam; Lutris+Heroic on Linux; Epic/GOG/
Xbox on Windows) was hardwired on. New library-scanners.json persists the
operator's disabled set (default all on; absent/malformed = all on);
all_games() gates each provider, so disabling one hides its titles from
every surface (console grid, native clients, GameStream app list, launch
resolve). GET /library/scanners lists this platform's scanners + state;
PUT /library/scanners/{id} toggles and emits library.changed — admin lane
only (the cert allowlist's exact-path /library match keeps both off the
LAN surface). The console's Library page grows a "Game sources" card with
one chip per scanner (platform-shaped by the API), EN+DE strings, story.
The scanners are slated to become plugins; the stable per-scanner ids are
the migration seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:58:05 +02:00
enricobuehlerandClaude Opus 4.8 ec84b30eae feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):

- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
  /api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
  the mTLS read-only allowlist); plugins.changed event; port-only registration
  (proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
  check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
  pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
  strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
  for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
  polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
  deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.

OpenAPI regen for the new endpoints follows in the next commit (built on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00
enricobuehlerandClaude Opus 4.8 75b3c94f60 fix(web): console sweep — pairing, displays, stats, logs, auth, i18n
ci / rust (push) Failing after 45s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
ci / docs-site (push) Successful in 1m6s
decky / build-publish (push) Successful in 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 6m1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 15m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
arch / build-publish (push) Successful in 20m32s
android / android (push) Successful in 20m50s
deb / build-publish (push) Successful in 20m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m41s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
Pairing:
- Refresh the paired-devices list after a native PIN pairing (the happy path never
  invalidated it, so a newly paired device stayed hidden until remount).
- Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so
  it now reads "PIN sent" instead of a false "Paired successfully".
- Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could
  never receive a PIN there.
- Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8).

Displays / Library:
- "Arrange displays" save refreshes the settings card (it rewrites the policy), without
  clobbering unsaved Custom edits (re-seed only when the draft still matches the server).
- Live-display list wrapped in QueryState so errors don't read as "no displays".
- "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo
  artwork (was dropped on save); per-card delete pending.

Stats:
- Distinct colour for the native "queue" latency stage (it collided with "capture").
- "Not measured on this path" note on the GameStream health chart; configured-bitrate
  target line on throughput; host-authoritative elapsed timer; LiveCard surfaces
  non-404 errors.

Shell / auth / i18n:
- SSR-stable locale: first client render matches the base-locale SSR (no hydration
  mismatch), then adopts the persisted/browser locale post-hydration.
- BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced
  into a post-login redirect loop.
- Logout checks the POST result before navigating; logs dedup by seq (StrictMode);
  login "next" keeps query/hash; Dashboard shows the active-session count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:06:39 +02:00
enricobuehlerandClaude Opus 4.8 a85be8e467 feat(displays): clearer virtual-display preset names + descriptions
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m15s
decky / build-publish (push) Successful in 18s
apple / swift (push) Successful in 4m55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
ci / rust (push) Failing after 7m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m21s
docker / deploy-docs (push) Successful in 24s
windows-host / package (push) Failing after 8m23s
ci / bench (push) Successful in 8m44s
arch / build-publish (push) Successful in 12m26s
android / android (push) Successful in 13m37s
deb / build-publish (push) Successful in 14m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m17s
release / apple (push) Successful in 22m53s
apple / screenshots (push) Successful in 20m47s
"Gaming rig" actually meant a dedicated/headless box you only ever stream from,
which confused users — rename it to "Headless box" and rewrite all five preset
summaries to be scenario-first and shorter (the console cards already show the
mechanics as badges). Updated across the host API summaries (mgmt.rs), the web
console labels (en/de), and the docs table + prose. The internal preset id
`gaming-rig` is unchanged (stable API / stored-policy / test contract).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:29:18 +02:00
enricobuehlerandClaude Fable 5 a011aebef5 feat(host,web): experimental PnP monitor-devnode disable for Exclusive sessions
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m9s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
windows-host / package (push) Failing after 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m54s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m21s
flatpak / build-publish (push) Failing after 2m26s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Failing after 19m24s
Second experiment against the connected-but-dark-head stutter (field-proven
on the reporter's box: unplugging his standby HDMI TV removes a metronomic
~4 s double-jolt; DDC/CI is a dead end for TVs — measured on the lab LG, VCP
0xD6 gets no I2C ACK). An Exclusive isolate only removes physical monitors
from the CCD topology; their PnP devnodes stay live, so every standby wake
(auto input scan, Instant-On HPD cycling) still triggers the full Windows
reaction: PnP arrival/removal, CCD re-evaluation, DWM invalidation — the
suspected hiccup mechanism (Apollo #368's Device-Manager-refresh signature).

- New `pnp_disable_monitors` display-policy axis (default off): orthogonal
  to presets like game_session/ddc_power_off, surfaced in GET/PUT
  /display/settings + the enforced list, carried through the layout
  transform.
- windows/monitor_devnode.rs: after the isolate takes, disable exactly the
  deactivated monitors' devnodes — CCD target → monitor device path
  (DISPLAYCONFIG_TARGET_DEVICE_NAME) → PnP instance id → CM_Disable_DevNode
  with CM_DISABLE_PERSIST, so a hot-plug RE-ARRIVAL stays disabled (that
  persistence is the whole point). Teardown re-enables BEFORE the CCD
  restore (+300 ms re-arrival settle) so restored paths have their monitors
  back. Precise selection — co-installed third-party virtual displays are
  never touched.
- Crash safety: instance ids journal to <config>/pnp-disabled-monitors.json
  before disabling; serve startup re-enables leftovers from a crashed host.
  Worst case is documented in the console help (Device Manager re-enable).
- Web console: second Experimental-badged toggle (the DDC block refactored
  into a shared ExperimentalToggle), EN/DE strings, preset-switch carry.

Verified: Linux 263 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:47:40 +02:00
enricobuehlerandClaude Fable 5 cca5008805 feat(host,web): experimental DDC/CI monitor power-off for Exclusive sessions
ci / web (push) Successful in 47s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 19s
apple / swift (push) Successful in 1m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
apple / screenshots (push) Successful in 5m28s
ci / bench (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 8m45s
android / android (push) Successful in 12m43s
deb / build-publish (push) Successful in 13m1s
arch / build-publish (push) Successful in 14m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m20s
ci / rust (push) Successful in 22m12s
The sole-virtual-display stutter investigation's active experiment: when the
Exclusive isolate deactivates a physical monitor, the dark-but-connected head
keeps getting serviced (monitor standby auto-input-scan / DP link churn) at a
seconds-scale cadence — the leading suspect for the periodic double-jolt. A
panel commanded off over DDC/CI (the VESA monitor-control channel in the video
cable) believes it has an owner and, on cooperating firmware, stops probing.

- New `ddc_power_off` display-policy axis (default off): orthogonal to presets
  like game_session, stored in display-settings.json, surfaced in GET/PUT
  /display/settings + the enforced list, carried through the layout transform.
- windows/ddc.rs: VCP 0xD6 power-mode control via the dxva2 Physical Monitor
  API. Deliberately DPMS-off (0x04, DDC stays responsive, signal return wakes)
  and never power-button-off (0x05, bricks-until-button on many monitors).
  Probe-before-write; every failure is skip-and-log — monitors without DDC/CI,
  OSD-disabled, or behind docks/KVMs degrade to a logged no-op.
- Manager wiring: panels commanded off immediately BEFORE the Exclusive CCD
  isolate (an HMONITOR — and with it the DDC channel — only exists while the
  display is active); teardown wakes them right after the CCD restore, where
  returning signal alone already wakes most firmware.
- Web console: an Experimental-badged on/off control on the display card,
  applied immediately like the game-session axis and preserved across preset
  switches; EN/DE strings incl. the wake-failure escape hatch (press the
  monitor's power button once, turn the option off).

Diagnostic value on top of the fix: if this kills a reporter's stutter, the
churn is monitor-firmware-initiated; if only topology=primary/extend does, the
driver services dark heads regardless — the two remaining root-cause classes.

Verified: Linux 258 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:32:36 +02:00
enricobuehlerandClaude Fable 5 8743f63406 polish(web): favicon, Punktfunk casing, display-save toast, idle stop button
- Favicon: the brand-mark lens SVG at /favicon.svg, linked from the root head.
- Branding: user-facing "punktfunk" → "Punktfunk" (tab title, app_name,
  pairing prose, the logo aria-labels/SVG titles) — protocol identifiers
  (punktfunk/1, _punktfunk._udp, binary/module names) stay lowercase.
- The display policy auto-saves on every preset pick / field edit with no
  signal; mount @unom/ui's sonner Toaster in the root and fire a success toast
  from the save funnel's onSuccess (new i18n key, en + de). First toast usage
  in the console — errors stay inline as before.
- The stop-session button rendered destructive-red even with nothing to stop:
  variant switches to the muted `secondary` while no session is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:37 +02:00
enricobuehlerandClaude Opus 4.8 79c4f4cbc1 feat(host): user-defined custom display presets
Save named bundles of the display-management policy (the six behavior axes
plus the game-session axis) as custom presets, alongside the built-ins. A
custom preset is data — stored in <config>/display-presets.json — not a Preset
enum variant, so DisplayPolicy::effective() stays pure and the built-in set is
untouched; applying one writes a Custom policy via the existing PUT
/display/settings.

- policy.rs: CustomPreset/CustomPresetInput + load/add/update/delete store
- mgmt.rs: GET/POST /display/presets + PUT/DELETE /display/presets/{id},
  surfaced on GET /display/settings
- web console: custom-preset cards with save-as / edit / delete + i18n
- regenerated api/openapi.json; docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:33:43 +00:00
enricobuehlerandClaude Opus 4.8 1992eb1c52 feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.

- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
  registry pools only what it owns, so gamescope managed/attach outputs are no
  longer double-owned by the registry AND the gamescope restore worker (fixes
  the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
  liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
  forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
  (kill_unit, applied to our transient unit AND the autologin stop -- validated
  live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
  on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
  pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.

Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:33:43 +00:00
enricobuehlerandClaude Opus 4.8 b28ddfc2d4 fix(web): clearer topology/identity copy, capped description width, mobile More-nav, preset spacing
Console polish on the Virtual displays card + shell:
- Topology help now leads with the streamed display's role (Extend/Primary/Exclusive) instead of
  the confusing physical-monitor-only framing; notes the headless case. Identity help spells out the
  actual behavior (stable per-client identity → the desktop reapplies that client's scaling/resolution
  on reconnect) + what Shared / Per-client / Per-client+resolution each do.
- Cap description/help width at max-w-prose so long help text isn't a full-viewport line on large screens.
- Mobile bottom nav: 8 flat tabs were too cramped → 4 pinned tabs + a "More" tab whose sheet holds the
  rest (Performance/Logs/Pairing/Settings), "More" highlighted when the active route is in the overflow.
- More breathing room under the "Preset" heading.

web tsc + biome + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:40:35 +00:00
enricobuehlerandClaude Opus 4.8 42ea12ad8f fix(web): unify display-field spacing (shared Field) + clearer layout help
- Every option in the custom form now renders through one `Field` wrapper (label →
  control → help at a consistent `space-y-3`), so the label→input gap is roomier and
  identical across keep-alive, the button groups, and max-displays — the first field no
  longer spaces differently from the rest.
- Reworded the multi-monitor layout help: it now says Auto is side-by-side and Manual
  gives a per-display X/Y editor "in the Live displays section below once two or more are
  streaming" — instead of pointing at an "arrangement table" that isn't visible until
  clients connect.

web tsc + vite build + biome-lint green; deployed on .21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:07:45 +00:00
enricobuehlerandClaude Opus 4.8 5b71a74da5 fix(web): explicit keep-alive Off/Keep toggle + roomier custom display form
Two UX fixes on the Virtual displays Configuration card:

- Keep-alive is no longer implicitly "on" by typing in the seconds field. It's an
  explicit two-button toggle — **Off** (tear down at disconnect) vs. **Keep for** [N]
  seconds — and the seconds input only appears when "Keep for" is selected. The
  duration is remembered across toggles, and the help text explains both modes.
- Opened up the cramped custom form: the fields container is `space-y-6` with more
  padding (`p-5`, rounded-lg), each option group is `space-y-2.5`, and the Save button
  sits below a divider — so it reads as sections with room instead of a pressed stack.

web tsc + vite build + biome-lint green; deployed on .21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:00:57 +00:00
enricobuehlerandClaude Opus 4.8 167b3ae251 feat(web): move Virtual displays to its own nav section; roomier preset grid
The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the
virtual-display config surface is large enough to warrant its own home.

- New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved
  DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and
  `nav_displays` i18n. Removed the displays card from the Host page/view.
- On its own page the card splits into two: **Configuration** (presets + custom axes) and
  **Live displays** (the live list + arrangement table) — room to breathe.
- Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger
  section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned
  "what it sets" badges so the cards line up.

web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:54:24 +00:00
enricobuehlerandClaude Opus 4.8 458790e8f4 feat(web): full virtual-display config surface — one-click presets + every axis editable
The Virtual displays card previously only exposed keep_alive/topology/max_displays as
editable custom fields; conflict/identity/layout (enforced since Stages 3-5) had no
controls, and the presets weren't surfaced as one-click options. Rework the card so the
whole policy is configurable WITHOUT any client connected:

- Presets front-and-center: each of the five (default/shared-desktop/hotdesk/workstation/
  gaming-rig) is a one-click row showing its story AND what it sets (keep-alive · topology ·
  conflict · identity badges), highlighting the active one. A click applies it immediately.
  gaming-rig stays disabled + "coming soon" (keep_alive: forever isn't cross-platform yet).
- Custom mode reveals EVERY axis editably — keep-alive, topology, conflict, identity, layout,
  max-displays — seeded from the current effective behavior, with a Save button. A reusable
  `Choice` button-group + a tolerant `tr()` label lookup keep it tidy.
- The live-display list + multi-monitor arrangement table stay below (they need a live
  session); the settings above work standalone.
- en+de i18n for the new controls; refreshed the effective-preview row to show all axes.

web tsc + vite build + biome-lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:38:40 +00:00
enricobuehlerandClaude Opus 4.8 4c8b960048 feat(web): display arrangement table — the Stage 5 console x/y editor
Completes Stage 5's web piece (design/display-management.md §6.2): a `DisplayArrangement`
editor in the Virtual displays card. For a ≥2-display group, it renders an x/y table over
the live displays that carry a stable identity slot (the manual-layout key), seeded from
the current computed positions; Save writes `PUT /display/layout` (via the generated
`useSetDisplayLayout`), which switches the host to a manual layout applied from the next
connect. Shared/anonymous displays (no identity slot) are omitted (they can't be pinned).

Also refreshes the now-stale `display_pending_note` copy (conflict/identity/layout ARE
enforced as of Stages 3-5) in en + de.

web tsc + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:32:02 +00:00
enricobuehlerandClaude Opus 4.8 b988053a31 feat(vdisplay): lifecycle state machine + display state/release API (Stage 1)
Stage 1 of design/display-management.md — the lifecycle core + the display
management surface:

- vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/
  Lingering{until}/Pinned) with acquire/release/expiry/force-release
  transitions. No I/O, no OS types — the platform-neutral distillation of the
  Windows manager's model. Unit + a 200k-iteration seeded property walk
  (no leaks / double-frees / refcount underflow across arbitrary interleavings).
- vdisplay/registry.rs: neutral snapshot/release facade over the per-OS
  lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux
  keep-alive (a per-session pool) lands in a following increment (needs GPU-box
  validation).
- windows/manager.rs: additive snapshot() + force_release() (no behavior change
  to the on-glass-validated path).
- mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release
  (tear down lingering/pinned now; refuses active). OpenAPI regenerated.
- web console: Virtual displays card gains a live-display list (polled) with
  per-row + release-all buttons and a linger countdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:32:03 +00:00
enricobuehlerandClaude Opus 4.8 21f3c1326f feat(vdisplay): display-management policy surface (Stage 0)
A user-configurable policy layer above the per-compositor VirtualDisplay
backends: keep-alive, topology, conflict, identity, layout, max-displays —
persisted to display-settings.json, editable from the web console, applied
per connect. Design: design/display-management.md.

Stage 0 stands up the surface and wires the two behaviors the existing code
can already express — the Windows monitor linger duration and the
"make the streamed output the sole desktop" topology — through it; every
other option is stored + echoed but not yet enforced (later stages). An
unconfigured host (no display-settings.json) keeps today's exact behavior.

- vdisplay/policy.rs: pure DisplayPolicy + 5 presets + JSON store (gpu-settings
  pattern) + EffectivePolicy; 9 unit tests.
- vdisplay.rs: resolve_topology(Auto); apply_session_env drives *_VIRTUAL_PRIMARY
  from the policy only when a settings file exists.
- windows/manager.rs: linger_ms() + should_isolate() read the policy when configured.
- mgmt: GET/PUT /api/v1/display/settings (bearer-only); PUT rejects keep_alive
  forever until the lifecycle stage. OpenAPI regenerated.
- web console: Host → Virtual displays card (preset picker + custom fields); en+de.
- docs-site: virtual-displays.md + configuration.md cross-links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:44:18 +00:00
enricobuehlerandClaude Fable 5 d86f1a6bdd feat(host,web): host log ring + GET /api/v1/logs + console Logs page
Remote debugging without shell access: a tracing layer tees every
event at DEBUG-and-up — independent of the RUST_LOG filter gating
stderr/host.log, so console-side debugging never needs a restart —
into a bounded in-memory ring (log_capture.rs, 4096 newest entries,
OnceLock singleton like config()), installed at both init sites
(stderr path in main, the Windows service file path). The mgmt API
serves it cursor-paged at GET /api/v1/logs?after=&limit= — bearer-only
and deliberately NOT on the mTLS cert allowlist (log lines can name
client identities and host paths). The web console grows a Logs page
(follow/pause · min-level filter · text search · eviction-gap badge);
polling self-paces: a non-empty page advances the after-cursor (new
query key → immediate refetch, drains backlogs), an empty page idles
at the 2s interval. OpenAPI regenerated; ring pagination/eviction,
layer wiring, and the authed route are unit-tested; Storybook story
included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:33:56 +00:00
enricobuehlerandClaude Fable 5 dacc3b5209 feat(host,web): multi-GPU selection — GPU inventory + preference API, web-console GPU card
- new crate::gpu (compiled on all platforms so the OpenAPI doc stays
  platform-independent): DXGI / sysfs GPU inventory with reboot-stable ids
  (PCI vendor:device + occurrence — LUIDs are per-boot), persisted auto/manual
  preference (<config>/gpu-settings.json, atomic temp+rename with in-memory
  rollback), one selection with precedence console preference >
  PUNKTFUNK_RENDER_ADAPTER > max VRAM and graceful fallback when the preferred
  GPU is absent, plus a live "in use" record (RAII session guard wrapped around
  every encoder open_video returns)
- fix: windows_gpu_vendor derived the encoder backend from DXGI adapter 0
  instead of the selected render adapter — on a hybrid box (e.g. Intel iGPU at
  index 0 + NVIDIA dGPU) the backend could disagree with the GPU the capture
  ring / IddCx render pin sit on. The NVENC 4:4:4 probe now also runs on the
  selected adapter (was: OS default), the codec/4:4:4 probe caches are keyed
  per selected GPU (were process-lifetime OnceLocks), and an explicit
  PUNKTFUNK_ENCODER conflicting with the selected GPU's vendor warns up front
- mgmt API: GET /api/v1/gpus (inventory + mode + preferred + next-session
  selection with reason + in-use GPU/backend/session-count) and
  PUT /api/v1/gpus/preference (validates mode/gpu_id before writing);
  openapi.json regenerated; the vdisplay render pin now also engages for a
  console preference (not just the env pin)
- web console: GPU card on the Host page — list with vendor + VRAM,
  Automatic / Prefer controls, Preferred / Next session / "In use · backend"
  badges, missing-preferred-GPU warning and env-pin note; en + de messages
- Linux: a matched manual preference picks the VAAPI render node and the
  NVENC-vs-VAAPI auto choice; auto mode is exactly the previous behavior

Validated live on the hybrid laptop (RTX 3500 Ada + Intel Arc Pro, which
enumerates twice — the occurrence ids disambiguate): enumerate, prefer,
bad-id 400, restart persistence, auto-restore keeping the stored pick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:57:26 +02:00
enricobuehlerandClaude Opus 4.8 ae51276a03 feat(web): consolidate paired devices, self-contained sections, docs + lint
Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
  their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
  the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
  drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
  Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
  removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.

Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
  plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
  Bun entry, launchers, systemd units, and packaging.

Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
  2.5.1; all lint issues fixed cleanly.

Also includes this branch's in-progress host, Apple client, packaging, and CI changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:05:22 +02:00
enricobuehlerandClaude Opus 4.8 da3f37317b feat(host): web-console performance capture — record stream stats, graph them
Arm streaming-perf-stats capture from the web console, play, stop, and review the
run as graphs; finished captures are saved to disk as browsable/exportable
recordings. Covers both the native punktfunk/1 path and GameStream.

- stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve,
  shared with the mgmt API + both streaming loops, mirroring NativePairing). The
  hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF
  for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic
  temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids;
  poison-resilient locks.
- native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing
  ~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput,
  loss/FEC deltas — with no new per-frame work beyond the cheap atomic check.
  FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's
  percentiles (without zeroing the Windows-relay path's fps/encode).
- mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live;
  recordings list/get/delete); api/openapi.json regenerated, in sync.
- web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live
  graphs while armed, recordings table (view / download-JSON / delete), and a detail
  view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput
  + health. Charts adapt to either path's stage set.

Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent
workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet
on-glass validated against a live session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:59:39 +00:00
enricobuehler 187738ed86 improve web ui 2026-06-26 05:43:34 +00:00
enricobuehlerandClaude Opus 4.8 5e7521fa5b feat(web/library): game library page — grid + custom-entry CRUD
Consumes the new library API (47f87a4) via the orval-generated hooks. A poster grid
over GET /api/v1/library (all stores merged), plus create/edit/delete for custom
entries — the admin-UI half of "create custom entries via the web console".

- GameCard: portrait (600×900) art with an onError fallback chain portrait → header
  → text placeholder (many Steam titles lack a 600×900 capsule). A store badge marks
  Steam vs Custom; only custom cards expose edit/delete.
- Inline add/edit form (title + portrait/hero/header URLs + optional launch command,
  mapped to LaunchSpec{kind:"command"}) wired to useCreateCustomGame /
  useUpdateCustomGame / useDeleteCustomGame; the CRUD id strips the `custom:` prefix;
  every mutation invalidates the library query. QueryState handles load/empty/error.
- Nav entry (LibraryBig) + en/de i18n strings.

`bun run lint` (tsc) and `bun run build` both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:00 +00:00
enricobuehlerandClaude Opus 4.8 73b33d4129 feat(pairing): delegated approval (§8b-1) — approve an unpaired device from the console
An identified-but-unpaired device that knocks on a pairing-required host is now
held as a pending request the operator approves from the web console — pairing it
with no PIN fetched out of band — instead of a flat reject.

- core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64,
  same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs
  --name sends it; the connector sends None (fingerprint-derived label).
- native_pairing: in-memory pending queue (note_pending dedups by fingerprint,
  evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending
  pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/
  bidi — untrusted wire input); add()/remove() roll back in-memory on a persist
  failure; pairing clears any stale pending knock.
- m3: the require_pairing gate records the knock (sanitized label) before
  rejecting; anonymous (certless) clients record nothing.
- mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name})
  and /deny; OpenAPI + tests; docs/api/openapi.json regenerated.
- web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/
  Deny, error-surfaced via QueryState); en+de strings.
- Also completes an in-progress NativeClient Sync refactor (receivers behind
  per-plane mutexes) that was left half-applied in the tree.

Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are
fixed here. Validated live on the GNOME box: knock (with a wire name, and a
malicious ANSI/bidi name that got neutralized) → pending → approve → the same
identity streams real video. Full workspace tests + clippy + fmt green; web tsc
clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client
follow-up. See docs-site pairing page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:14:05 +00:00
enricobuehlerandClaude Opus 4.8 7235c8dba9 feat(web): "Pair a device" card — native pairing from the console
Completes the web-UI native (punktfunk/1) pairing flow the unified host backs.
The Pairing page now leads with a native card that arms a window via the mgmt API
and DISPLAYS the host PIN (the SPAKE2 ceremony is host-mints / client-enters) with
a live countdown + Cancel, plus a paired-devices list with unpair — no journalctl.
The existing Moonlight PIN-submit moves into its own section below.

Uses the orval-generated `native` hooks (regenerated from the committed OpenAPI on
build) + en/de strings. Validated end-to-end through the web server's proxy + cookie
auth: login → status → arm (PIN shown) → clients. tsc + production build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:03:33 +00:00
enricobuehlerandClaude Opus 4.8 16369be48c feat: client-selectable compositor (protocol → host → client → C ABI → mgmt → web)
A client can now request which compositor backend the host drives its virtual
output on (gamescope/KWin/Mutter/wlroots). The host honors the request if that
backend is available, else falls back to auto-detect and reports the resolved
choice back — wire-compatible both directions (no ABI bump).

Protocol (punktfunk-core):
- New CompositorPref (config.rs): Auto|Kwin|Wlroots|Mutter|Gamescope with
  u8/name mappings. Appended as one optional byte to Hello (client preference)
  and Welcome (host's resolved choice). Both decoders already tolerate trailing
  bytes, so old↔new interop is preserved — ABI_VERSION stays 2. Round-trip +
  back-compat (truncated-message) tests.
- C ABI: punktfunk_connect_ex(compositor) + PUNKTFUNK_COMPOSITOR_* constants;
  punktfunk_connect delegates with AUTO, so the existing symbol is unchanged.
  NativeClient::connect / worker_main thread the preference through.

Host:
- vdisplay::available() enumerates usable backends via cheap, side-effect-free
  probes (KWin zkde global, gamescope binary+version, GNOME/Sway env), plus
  Compositor id/label/as_pref/from_pref/all helpers.
- m3 handshake resolves the preference to a concrete backend during the
  handshake (pick_compositor pure + resolved logging), reports it in Welcome,
  and threads it into virtual_stream (replacing the unconditional detect()).
- mgmt GET /v1/compositors lists every backend with availability + the
  auto-detected default (OpenAPI regenerated).

Client:
- punktfunk-client-rs --compositor NAME; logs the host's resolved choice from
  the Welcome ("session offer … compositor=…").

Web console:
- Host page gains a Compositors card (availability + default badges) via the
  codegen'd useListCompositors hook; en/de strings added.

Also fixes a pre-existing, env-dependent test-isolation bug:
mgmt::tests::paired_clients_list_and_unpair seeded the real
~/.config/punktfunk/paired.json (AppState::new loads it), so a real
GameStream-paired client leaked into body[0] on a dev box — now cleared first.

Live-validated against headless KWin: --compositor kwin honored, --compositor
mutter falls back to kwin (available=[kwin, gamescope]), resolved choice
round-trips to the client. Tests: +6 (wire/back-compat, resolution precedence,
endpoint); workspace green, clippy/fmt clean, C ABI harness PASS at abi_version=2,
web typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:45:41 +02:00