A channel nobody has published to answers `manifest.json` with a 404, and the
check reported that the same way it reports a dead registry or a bad signature:
"Last check failed: feed returned HTTP 404". Every host on the stable channel
shows it today, because the stable manifest only publishes when someone
dispatches `announce` for a release tag — so the first thing an operator sees
from the new Updates card is a red failure caused by nothing being wrong.
The shared checker now distinguishes the two. `feed::fetch_manifest_blocking`
returns a typed `FeedError` instead of a string, and only a 404 on the manifest
ITSELF becomes `NotPublished` — a 404 on the detached signature still fails
loudly, because that is the half-published pair the manifest-then-signature
upload order can produce, and it must stay fail-closed.
The host carries that through as `UpdateStatus.not_published`, mutually
exclusive with `last_error`. It is benign only while no manifest has ever been
seen for the channel: once a check has succeeded, the same 404 means the feed
LOST a document it used to serve, which stays an error. The console then shows a
plain sentence naming the channel instead of the failure banner, and "None
published yet" rather than "Not checked yet".
The Linux client makes the same distinction but deliberately NOT the same
choice: `--check-update` keeps exiting 1 and keeps `error` set, because its
consumer is a shell script and an empty channel is the absence of evidence that
this build is current — not a confirmation that it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found on glass. The logs card has no CardHeader, so it puts the top padding back
itself — but only at one breakpoint. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`,
and tailwind-merge resolves conflicts only within the same variant: a bare
`pt-6` cancels `pt-0` and leaves `sm:pt-0` standing. Measured on .173: 24px of
top padding at 420px wide, 0px at 1280px, with the level filters and the search
box sitting flush against the card border.
It is the same trap `components/ui/card.tsx` documents for `p-0` — a variant
cancelling its unprefixed counterpart and nothing else — just in the other
direction, so the note now points both ways. This card was the only offender.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**The streamed-screen pin could still be clobbered by three other write paths.**
Deferring it to the server's value on Save fixed the reported sequence but not
the general case: the draft is only re-seeded while it is CLEAN, so once there is
an unsaved edit its `capture_monitor` is frozen at whatever it was before the
operator used the picker — and `applyAxis` (which spreads the last saved policy),
the built-in preset switch and the custom-preset apply all put that stale value
back. Every write path reads `serverCaptureMonitor()` now; no path spreads the
draft's copy.
**The session⇄game grace input had no accessible name.** That card has its own
`Field` and only DisplayCard's was fixed, so the number input was still announced
as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control,
`fieldset`/`legend` for the two button groups. Verified in a browser — zero
inputs without an accessible name across the Displays page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A verification pass re-read every finding from the original sweep against the
code on this branch rather than against the commit messages. It found that four
of them were still broken, two because the edit I made was inert. Commit
messages claim; code decides.
- **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook`
as a bare directory name, and tsc silently skips dot-prefixed directories in
that form — so the entry typechecked nothing at all. Proved it by planting
`export const __probe: string = 1` in `.storybook/preview.tsx` and watching
`bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same
probe now fails as it should.
- **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top
of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status
to pending in the same update, so it cleared a flag that was already changing.
The green "PIN sent" note therefore still greeted the next pairing attempt over
an empty PIN box. It now resets on the transition that actually matters:
`pin_pending` going false → true.
- **The session⇄game controls had the enforcement flag inverted**, and I never
touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build
enforces everything", when the contract says the opposite in as many words:
"Empty on a platform with no launch path (macOS), so the console can say so
instead of offering a switch that does nothing". On exactly the platform the
flag exists for, every control stayed live and reported success for an axis the
host would never act on. Absent still means "assume it acts" — that is the
compatible reading for an older host, and a different case from present-empty.
- **Logout stopped revoking after a restart.** The epoch was a module-level
counter starting at 1, so it revoked within one process run and then reset —
and since the seal key derives from the stable mgmt token, a cookie captured
before a restart unsealed fine and was accepted again for the rest of its
7-day TTL. One service restart undid the whole fix. It persists next to the
host's config now. Verified: log out, restart the console, the captured cookie
still 401s, a fresh login still works.
Two more the pass rated as partial, both worth closing:
- The plugin-UI response filter was a denylist of four header names, so
`Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session`
and sign the operator out of the console, on our own origin, because the iframe
is same-origin by design. It is an allowlist now; a plugin-supplied CSP,
`X-Frame-Options` or CORS header no longer speaks for us either.
- A half-configured TLS setup now refuses to start instead of logging a warning
and serving anyway. Neither shape can work — one path missing puts the login
password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the
cookie Secure so the browser drops it and login can never stick. Exiting with a
reason beats a console that looks fine and is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**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>
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>
`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>
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>
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>
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>
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>
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>
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>
Three things were wrong with the host-card OS icons.
The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.
Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).
On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.
Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.
Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install
gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs
under systemd-run --user so the script's own restart of punktfunk-host can't kill it
mid-build (a child in our cgroup would die with us). Outcome without version equality —
which a source rebuild can't promise: a failed build leaves the host alive to report it
(unit watched to failure, log attached); a successful one restarts us, and the new
source_build intent flag makes intent-present-at-boot itself the success signal (the
script only restarts the host after a successful install). The console stops treating
a live long build as a timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
The pending-devices table's four p-card cells overflow a 360-430 px
viewport, and the actions cell - Approve - ended up past the right edge
of an overflow-auto wrapper inside the page's overflow-x-hidden column:
the button existed but could not be seen or reached (landscape is wide
enough, which is why it "worked" there). The name cell now flexes and
truncates, and the fingerprint/age columns collapse into a sub-line
under the name on narrow screens instead of widening the row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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.
The stage split is the thing an fps-shortfall report is diagnosed from, and
it cannot be read without knowing the backend behind it: a p50 `submit` of
10 ms means "the GPU's CSC+encode throughput is the ceiling" on one backend
and something else entirely on another. The capture's meta carried the mode,
codec and client but neither the encoder nor the GPU, so every report so far
cost a round-trip asking which one it was.
Both come from `pf_gpu::active()` — the record the encoder open itself
writes — so they name the branch that really opened rather than a re-derived
guess that goes stale the moment a backend gains an internal fallback. Read
once per capture, not per frame.
The console shows them next to the mode in the recording's header. Both
fields are `serde(default)`, so a recording made before this still loads and
simply omits them.
Refs #9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GameStream and native (punktfunk/1) clients pair into separate stores, and
`/status` only ever reported the GameStream certificate count. Native is the
DEFAULT plane, so a host whose clients had all paired normally — and which
Moonlight had never touched — showed "0 paired" on the dashboard.
Report the native count alongside it, the way the tray's own summary route
already does, and sum the two in the card.
Closes#10
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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)
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)
"No games found yet." sat flush against the top edge of its card on any
screen ≥640px. The call sites overrode CardContent's padding with a bare
`p-8`, but tailwind-merge only resolves conflicts within a variant: `p-8`
cancels `p-4` and leaves `sm:p-6 sm:pt-0` standing, so the desktop
breakpoint kept the zero top inset that exists for cards WITH a header.
These three have none.
Uses `flush` — the escape hatch CardContent documents for exactly this —
so the padding is owned outright at every breakpoint instead of fought
from the outside. The library grid is the one that was reported; the
store catalogue and the recordings list are the same idiom with the same
bug. PairedDevices deliberately keeps its `p-6`: it is a table under a
header, where pt-0 is the intended look.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 71fc47f32af7c74327a26425035298f8e6464c96)
`applyCustomPreset` builds a FRESH policy object instead of spreading the
draft, so every field it doesn't name is dropped. `capture_monitor` was
not named — so picking one of your own saved presets silently took a host
that was mirroring a real monitor and put it back on a virtual display.
The same omission in the Custom switch would have done it there too.
Found on-glass against a running serve on .136: the PUT is whole-object,
so the console is the only thing keeping the orthogonal axes alive, and
these two sites were the ones building a policy from scratch rather than
from what was already in force.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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.
Rendered the Dashboard stories headlessly and looked at them, which is how this
turned up: with no cover the slot was an empty grey rectangle, reading as
something that failed to load rather than something absent.
Plenty of rows will never have art — an operator-typed command has no catalog
entry behind it, a custom entry may carry none, and nothing does until
`/library` has loaded. The slot keeps its fixed size so rows stay aligned when
only some titles have covers; it just holds a gamepad glyph instead of nothing.
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>
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.
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>
`<CardContent className="p-0">` never removed the padding it looked like it
removed: tailwind-merge resolves conflicts within a variant, so `p-0` cancels
`p-4` but leaves `sm:p-6` standing. Every call site that used it nests a
CardHeader inside, which brings its own `sm:p-6` — so at >=640px the title sat
24px further in than its neighbours'. Visible on the Plugins page as 'Installed
plugins' not lining up with 'Plugin runner'.
Give CardContent a `flush` prop that omits the padding outright, and use it at
the three call sites (Store, Pairing, Stats) rather than fixing the symptom
three times and leaving the footgun in place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things looked wrong in the store:
The cards had no space above the icon. `CardContent` zeroes its own top padding
(`pt-0`/`sm:pt-0`) because it normally sits under a `CardHeader` that supplies
it — but these cards have no header, so the top padding simply vanished while
the other three sides kept `p-card`. `p-card` does not undo it: `card` is a
custom `--spacing-*` token, which tailwind-merge does not recognise as a
spacing value and so never dedupes against `pt-0`, leaving the longhand to win.
Both header-less cards in this section now restore it explicitly, at both
breakpoints.
Platform chips read "windows" / "linux". Those are the catalog's platform
IDENTIFIERS (the index validator pins `linux | windows | macos`), which is right
for the wire and wrong on screen; the card now maps them to display names —
Linux, Windows, macOS. Proper nouns, so deliberately not routed through i18n.
Adds Store/StoreCard stories covering the padding, the platform labels, and the
installed / update / incompatible / blocked / external states, so the card can
be eyeballed without a host or a catalog.
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>
CardContent is shadcn's header-adjacent variant (`p-4 pt-0 sm:p-6 sm:pt-0`),
but the four Live-status tiles use it with no CardHeader. tailwind-merge lets
the call site's unprefixed `p-4` cancel the base `pt-0`, yet nothing cancels
`sm:pt-0` — so the tiles lost their top padding at >=640px only, and the
content sat against the top edge. Restores the top inset at the sm breakpoint
and lets the content fill the stretched grid cell so it stays centred when a
sibling tile is taller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The main content wrapper used p-6 (24px) on mobile while the mobile top bar uses
px-4 (16px) — an inconsistent, oversized side gutter that ate into the usable
width on phones. Drop it to a 16px side gutter (px-4) on mobile, matching the top
bar; sm+ padding is unchanged. Also make the shared Card padding responsive
(p-4 on mobile → p-6 from sm up) so card content isn't double-inset on small
screens. Desktop layout is identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
Address findings from a repo security review:
- core: add a sliding-window anti-replay filter over the AEAD-authenticated
sequence in Session (poll_input/poll_frame), closing the input-replay gap the
data plane previously left to the LAN/VPN trust assumption. 4096-deep window,
unit-tested; the encrypted loopback suite confirms no false drops.
- clients: write the mTLS client private key 0600 and lock the config dir 0700
on Unix (it was world-readable at the umask default), re-locking existing
stores on load. pf-client-core::trust plus the probe's own identity writer.
Windows keeps the %APPDATA% ACL; Android/Apple already wrap the key.
- web: fix a post-login open redirect — resolve `next` via URL and require it to
stay same-origin, rejecting `/\evil.com` and tab/encoding variants the old
`!startsWith("//")` guard missed. Also fixes the dead safeNextPath helper.
- ci: SHA-256-pin the BtbN FFmpeg DLLs bundled into the signed Windows installer
(were fetched from the rolling `latest` tag unverified); fails closed on a
re-roll, matching the VB-CABLE gate.
- ci: fail-open fork-guard on the Windows/Apple host-mode PR build jobs that
share runner labels with the signing jobs. Definitive fix stays server-side
(Gitea outside-collaborator approval / isolated PR runners) — see the notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
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>
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>