f3a39df7b3948543b0802d4e9cc155a14a3ebf1a
2273
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f3a39df7b3 |
test(host/audio): prove a reopen recovered with a live uplink, not one frame
`reopens_after_push_death` failed about one run in nine, and widening its timeout did not help — the earlier commit blamed the backoff and was wrong. The pump drops whatever queued while it was down: audio from before the device came back is stale, so a fresh instance drains the channel right after opening. The harness counts `opens` from the START of the open, so the moment the test sees the counter move, the pump has not reached that drain yet. The single frame it then sent landed inside the drain window and was discarded exactly as designed, leaving the test waiting for audio that was never going to arrive. So the test now keeps feeding, which is what a real uplink does and what the drain assumes. The sequence advances each time or the de-jitter reads the repeats as duplicates and drops them for a second, correct reason. Production behaviour is unchanged: this was the test asserting something the pump never promised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
23f1debe69 | Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo | ||
|
|
ff190e9825 |
fix(web): the logs filter row stops touching the card's edge on desktop
windows-host / package (push) Failing after 28s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 2m13s
ci / docs-site (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 3m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 34s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish (push) Successful in 3m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
deb / build-publish-host (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / deploy-docs (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 3m1s
ci / rust (push) Successful in 6m29s
arch / build-publish (push) Successful in 7m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m42s
Found on glass. The logs card has no CardHeader, so it puts the top padding back itself — but only at one breakpoint. `CardContent` is `p-4 pt-0 sm:p-6 sm:pt-0`, and tailwind-merge resolves conflicts only within the same variant: a bare `pt-6` cancels `pt-0` and leaves `sm:pt-0` standing. Measured on .173: 24px of top padding at 420px wide, 0px at 1280px, with the level filters and the search box sitting flush against the card border. It is the same trap `components/ui/card.tsx` documents for `p-0` — a variant cancelling its unprefixed counterpart and nothing else — just in the other direction, so the note now points both ways. This card was the only offender. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f66de3eba4 |
fix(web): close the last two high-severity items from the closeout audit
**The streamed-screen pin could still be clobbered by three other write paths.** Deferring it to the server's value on Save fixed the reported sequence but not the general case: the draft is only re-seeded while it is CLEAN, so once there is an unsaved edit its `capture_monitor` is frozen at whatever it was before the operator used the picker — and `applyAxis` (which spreads the last saved policy), the built-in preset switch and the custom-preset apply all put that stale value back. Every write path reads `serverCaptureMonitor()` now; no path spreads the draft's copy. **The session⇄game grace input had no accessible name.** That card has its own `Field` and only DisplayCard's was fixed, so the number input was still announced as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control, `fieldset`/`legend` for the two button groups. Verified in a browser — zero inputs without an accessible name across the Displays page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f2e1b9872c |
fix(web): four "fixes" from this branch that did not actually fix anything
A verification pass re-read every finding from the original sweep against the code on this branch rather than against the commit messages. It found that four of them were still broken, two because the edit I made was inert. Commit messages claim; code decides. - **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook` as a bare directory name, and tsc silently skips dot-prefixed directories in that form — so the entry typechecked nothing at all. Proved it by planting `export const __probe: string = 1` in `.storybook/preview.tsx` and watching `bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same probe now fails as it should. - **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status to pending in the same update, so it cleared a flag that was already changing. The green "PIN sent" note therefore still greeted the next pairing attempt over an empty PIN box. It now resets on the transition that actually matters: `pin_pending` going false → true. - **The session⇄game controls had the enforcement flag inverted**, and I never touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build enforces everything", when the contract says the opposite in as many words: "Empty on a platform with no launch path (macOS), so the console can say so instead of offering a switch that does nothing". On exactly the platform the flag exists for, every control stayed live and reported success for an axis the host would never act on. Absent still means "assume it acts" — that is the compatible reading for an older host, and a different case from present-empty. - **Logout stopped revoking after a restart.** The epoch was a module-level counter starting at 1, so it revoked within one process run and then reset — and since the seal key derives from the stable mgmt token, a cookie captured before a restart unsealed fine and was accepted again for the rest of its 7-day TTL. One service restart undid the whole fix. It persists next to the host's config now. Verified: log out, restart the console, the captured cookie still 401s, a fresh login still works. Two more the pass rated as partial, both worth closing: - The plugin-UI response filter was a denylist of four header names, so `Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session` and sign the operator out of the console, on our own origin, because the iframe is same-origin by design. It is an allowlist now; a plugin-supplied CSP, `X-Frame-Options` or CORS header no longer speaks for us either. - A half-configured TLS setup now refuses to start instead of logging a warning and serving anyway. Neither shape can work — one path missing puts the login password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the cookie Secure so the browser drops it and login can never stick. Exiting with a reason beats a console that looks fine and is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
b9b0df349d |
fix(web): the charts stop lying about time, and logging out logs you out
The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
de17ceb8f8 |
fix(web): a newly installed plugin shows up on its own, and the display form can be used without a mouse
Installing a plugin left the sidebar unchanged until a reload. The reason is timing, not caching: the host restarts the scripting runner AFTER the job reports done, and the plugin only registers its UI once that comes back — several seconds later, by which point the one-shot invalidation had already run and found the old list. The nav then waited out the 30 s idle poll, which in practice meant "until I reloaded". Anything that changes the installed set now switches the directory to a 2 s poll for a minute, so the entry lands about a second after the plugin actually comes up. Measured end to end in a browser: 29 s → 7 s, with the plugin registering at 6 s. The plugin entries also never animated. They are rendered outside the `motion.nav` that carries the variants and the stagger, so they inherited neither and simply appeared — most visibly in exactly the case above, where one shows up in a nav that is already on screen. They get their own animation container now, matching the main nav. (A motion-wrapped div around the link, not `motion(Link)`, which erases TanStack's typed `params`.) The accessibility pass on the display form, where the console's densest controls live: - The Custom block's numeric inputs had a `<label>` with no `htmlFor` next to an `<input>` with no `id`, which labels nothing at all — a screen reader announced them as unnamed spin buttons. Single controls are paired properly now; the button groups became real `<fieldset>`/`<legend>`, which is what they are. - Every option group signalled its active choice with fill colour alone. They carry `aria-pressed` now, so the state is available to assistive tech and not only to people who can compare two button variants. - `QueryState`'s error branch is a live region, so a query that fails announces the failure instead of silently swapping one region for another. - Motion honours `prefers-reduced-motion` instead of overriding it. - `<html lang>` follows the locale instead of claiming "en" while the app renders German. Verified: switching to de flips the attribute. - "Close menu", "Language" and "Loading" went through the message catalogue. Also: ten dead message keys removed (a whole removed Clients page and the old Settings token field), and the README no longer tells operators to set the management token under "Settings → API token" — that field is gone and the token has been server-side only for some time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e505aba41 |
fix(web): the console stops swallowing the host's answer when it says no
The host writes genuinely useful refusals — "entry is owned by provider `x`, update it through its reconcile" — and a dozen call sites threw them away. The pattern was always one of two: a mutation whose `error` nothing rendered, or an `await mutateAsync(...)` with no catch, which additionally produced an unhandled rejection. Either way the operator clicked, nothing visible happened, and the thing they asked for silently hadn't. Fixed at each site, with the host's own message shown where there is one: - Adding or editing a library entry kept the form open and said why, instead of closing it as if it had saved and taking the typing with it. Deleting one reports the refusal rather than leaving the card sitting there. - The GPU preference, capture start/stop, recording delete and download, and the dashboard's stop-session / request-keyframe / end-game all report failure. The failed capture STOP is the one that mattered most: it is "stop & save", so a swallowed error meant minutes of recording vanished with nothing on screen. - The recordings Download had a comment claiming the detail view surfaces its errors. It only does that for the selected row, and Download is on every row. Two related fixes in the same area: - `apiFetch` no longer navigates to /login synchronously from inside whichever call noticed a 401 — very often a background poll the user never started. Tearing the page down mid-render took unsaved editing state with it, which the Displays page explicitly models. It defers a beat and coalesces, so a burst of parallel 401s schedules one navigation. - The plugin liveness probe treated the auth gate's 302 → /login → 200 HTML as a healthy plugin, and rendered the console's own login page inside the plugin's iframe. It also gave up permanently on the first failed probe, so the runner restart at the end of every install threw away whatever was open in another plugin. It rejects the redirect and keeps probing on a slower beat while down. `apiErrorMessage` moves out of the display card into src/lib/errors.ts, since half the console needs it now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4a5d4b0a71 |
feat(web): the console follows the host's events instead of asking ten times a minute
The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
55e01c1460 |
fix(web): stop shipping 7 MB of sound the console cannot play
`@unom/ui/button` reaches `sound/defaults.js`, which resolves two game-UI sprite sheets with `new URL(…, import.meta.url)` at module scope — a 4.8 MB .wav and a 2.2 MB .mp3. Vite emitted both into the build, so they rode into the Windows installer and the .deb. The console never mounts UnomProviders, so no player is bundled and not one byte of it could ever be played. A build-time rewrite of those two expressions takes the asset payload from 8.2 MB to 1.5 MB; the login page and the button chunk are unchanged. Deleting the plugin is the whole revert if the console ever wants click sounds. Also: - `bun run dev` forwards the management bearer, so developing against a real host stops 401ing into a /login bounce that dev has no gate to satisfy. - `check-i18n` runs after `build`, not only inside `codegen`. It exists to stop a zero-message console shipping, and the CI job and the installer build both install with `--ignore-scripts`, so it had never once run where it mattered. - Bun's idle timeout goes from its 10 s default to 120 s. The host sends SSE keep-alives every 15 s, so anything long-lived proxied through the console was cut by us first — which the event stream is about to depend on. - The typecheck covers the Storybook config and preview. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
10a1863cc7 |
test(host/audio): give the reopen tests longer than the backoff they wait for
`wait_until` allowed 200 × 10 ms — exactly the 2 s `backoff_start` the reopen path spends before it can succeed. On a warm, idle machine it wins the race; on a cold binary or a loaded box it does not, and `reopens_after_push_death` failed 3 of ~5 cold runs while gating this branch. CI is always cold. Six seconds costs nothing when the test passes and only delays a genuine failure, so the budget now clears the backoff with room to spare. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6a4ffcb15c |
fix(client): HDR stops leaking out of the stream and blowing out the console UI
ci / web (push) Successful in 1m3s
ci / rust-arm64 (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 3m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
deb / build-publish (push) Successful in 5m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 27s
android / android (push) Successful in 8m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m12s
arch / build-publish (push) Successful in 9m35s
flatpak / build-publish (push) Successful in 6m57s
ci / rust (push) Successful in 10m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m17s
apple / swift (push) Successful in 5m10s
apple / screenshots (push) Canceled after 19m1s
The gamepad/console UI looked right on launch, and wrong forever after the first HDR session: connect to an HDR host, disconnect, and the UI came back overblown with wrong colours. The UI is not its own renderer. It is a `pf_presenter::overlay::Overlay` composited into the SAME swapchain the stream used, and it draws plain sRGB with no HDR awareness at all — so the swapchain's colorspace decides how its pixels are read. `present` switches SDR↔HDR10 from the FRAME's colour signalling, and a UI-only present is `FrameInput::Redraw`, which carries none: the mode block is skipped entirely and nothing ever hands HDR10 back. The UI's sRGB mid-tones were then emitted as PQ code points, i.e. near-peak nits. `leave_hdr` drops back to SDR, called where the UI-only present already happens and gated on the existing `browse_idle` — Browse mode with no live connector, i.e. the UI owns the screen. That covers every route back to the UI rather than just the Ended/Failed arms, and it is guarded internally so idle iterations stay free. It also bails when minimized, which is load-bearing rather than an optimization: `recreate_swapchain` keeps the old swapchain at a zero extent, but `set_hdr_mode` would by then have rebuilt the CSC and overlay pipes against the SDR format — mismatched against live HDR10 images. `present` early-returns on a zero extent above its HDR block, so this was unreachable until a caller outside `present` existed. Deliberately not applied to the `resize_scrim` arm of the same present: that scrim is a mid-stream gap in a session that is still HDR, and flipping there would rebuild the swapchain twice per resize. Does not address the adjacent case: an overlay drawn DURING a live HDR session (the stats HUD, the resize scrim) is blown out the same way. That needs the overlay to PQ-encode when `hdr_active`; dropping to SDR is only correct here because the console UI shows exactly when no stream is live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a7143a6510 |
Merge origin/main into audio/mic-latency-echo
Main moved through the same surfaces while the audio work was in flight, so three files needed hand-resolution: - clients/windows/src/app/settings.rs — main gave Windows its speaker and microphone endpoint pickers, the gap this branch could only report. Both keep their rows: the pickers, then Echo cancellation, and the microphone description keeps the sentence naming the mute chord. - crates/pf-console-ui/src/screens/settings.rs — both sides grew the couch row list. Main's seven new rows and Echo cancellation are all reachable in Gaming Mode; the count is 22 and the rationale comment names echo cancellation among the fields that would otherwise be unreachable there. - crates/pf-presenter/src/run.rs — both sides added a stats test at the same line. Both are kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43a631ea9c |
fix(clients): a host that re-keys stops locking the client out for good
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m25s
docker / deploy-docs (push) Canceled after 25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 5m20s
arch / build-publish (push) Successful in 6m52s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Canceled after 7m17s
deb / build-publish-host (push) Successful in 5m45s
apple / screenshots (push) Canceled after 1m5s
ci / rust (push) Canceled after 7m24s
flatpak / build-publish (push) Canceled after 3m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m47s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 11s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reinstall a host, wipe its ProgramData, or otherwise regenerate its identity, and the desktop clients refused it forever: "Host identity rejected — wrong fingerprint, or the host requires pairing", including immediately after a successful re-pair. There was no way out of it from the UI — the host list showed two cards for one address and forgetting the wrong one was a guess. `KnownHosts::upsert` matches on the FINGERPRINT, which is what lets a host that moved address keep its record and everything the user set on it. A host that changed identity matched nothing, so pairing appended a SECOND record for an address that already had one, and `find_by_addr` returned whichever came first in the file — the dead one, every time. Trust decisions (PIN ceremony, delegated approval, TOFU accept, headless pair — all funnelled through `persist_host`, plus the Windows shell's two direct upserts) now go through `upsert_trusted`, which retires any OTHER record for that address. Retired means DELETED, not demoted: a record whose certificate the host no longer holds cannot connect, so keeping it only reproduces the two- cards-one-address confusion this fixes. What described the box rather than the identity — its MAC, its OS chain, the bound profile, the pinned cards, when it was last used — moves onto the record that survives, so a reinstall doesn't quietly cost the user their setup. What described the dead identity does not: `paired` and `clipboard_sync` are decisions about one specific certificate and have to be made again for a new one, and the retired record's stable id stays retired (a deep link written from it falls through to the `host=` recovery the link grammar already specifies). Only trust decisions may retire a record. The wake path's address re-key and every learn-from-advert path stay on plain `upsert`: those are driven by unauthenticated mDNS, and letting an advert delete a saved host by claiming its address would trade this bug for a much worse one. A plain reconnect still fails closed on a pin mismatch — nothing here changes what the pin is checked against. Stores that already hold the duplicate recover on the next connect, not at load: which of two records is live isn't knowable at load time and guessing wrong would throw away the good one. Instead `find_by_addr` stops being positional — a real fingerprint beats a placeholder, and among real ones the newest trust decision wins, since records are only ever appended by one. The next successful pair then cleans the store up for good. Every lookup that picks a pin or a per-host decision for a connect now goes through it (the session's pin and clipboard read, the deep-link resolver, orchestrate's plan, both speed tests, the CLI's --wake and --library, which had also been ignoring the port), and an advert's learned MAC/OS lands on the record it identified rather than on a stale namesake that merely came first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6af067da2d |
fix(client): the bottom rows stop smearing — the decode pool is taller than the picture
ci / web (push) Successful in 1m49s
ci / rust-arm64 (push) Successful in 2m19s
ci / docs-site (push) Successful in 2m29s
ci / rust (push) Successful in 7m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish (push) Successful in 4m17s
deb / build-publish-host (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m28s
flatpak / build-publish (push) Successful in 6m8s
apple / swift (push) Successful in 5m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 13m5s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m10s
apple / screenshots (push) Canceled after 7m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m57s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 5m3s
A user's 1080p stream repeated its last row of pixels over the final few rows, so the image looked stretched at the bottom. The Vulkan-Video CSC pass sampled the decoded planes with the fullscreen triangle's normalized 0..1 UVs, but its render target is built at the CROPPED frame size. Those are not the same rectangle: FFmpeg sizes the decode pool from `avctx->coded_*`, and H.264 codes `16 * mb_height` — so a 1080-row picture decodes into a 1088-row pool. Destination row 1079 sampled source row ~1087.5, dragging the 8 alignment rows into view and squashing the picture 0.7%. Encoders fill that padding by replicating the last picture line, which is why it reads as a smeared bottom row rather than garbage. Confirmed on glass (.173, RTX, H.264 1080p, vulkan-video): Vulkan Video first frame width=1920 height=1080 pool_w=1920 pool_h=1088 `VkVideoFrame` now carries the pool extent and `record_csc` takes a `uv_scale`, written to the shader's `params.zw` — which the CSC shader already reserved for a use like this. The chroma cositing offset is unchanged and stays correct: `textureSize` reports the pool width, which is the space the scaled UV is already in. Only the Vulkan-Video path passes a scale below 1.0. D3D11VA already clamps this in its VideoProcessor blit (the same bug, seen as a green bar there because DXVA padding is uninitialized rather than replicated); dmabuf imports its planes at the crop over the real stride; PyroWave allocates its ring at exact stream dims. Apple and Android crop at the OS layer. Every other call site passes [1.0, 1.0], so the change is inert there. Also adds a one-time first-frame layout log mirroring the D3D11VA one, so the frame-vs-pool gap is visible in the field instead of having to be re-derived from FFmpeg internals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e877ad25f |
fix(android): a mute is a pause, not a hole the host tries to conceal
The Android uplink kept advancing `seq` while muted, so the first frame after an unmute looked to the host like loss the width of the mute. The de-jitter reads that as a gap: up to five concealment frames of stale voice, and a seq gap counted in the uplink-health line. Past 600 ms the pump's stale flush resets the chain first and hides it, which is why the usual long mute looks fine — a quick toggle does not. Freeze `seq` while muted, as the desktop uplink already does, so the frame after an unmute continues the chain. `reset_stream` says it plainly: a pause is not loss, and must not conceal or count a gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0c4a543831 | Merge branch 'audio/desktop-mute-and-aec-toggle' into audio/mic-latency-echo | ||
|
|
4c98e2f788 | Merge branch 'audio/android-mic-mute' into audio/mic-latency-echo | ||
|
|
6be8e5d6fa | Merge branch 'audio/apple-mic-mute' into audio/mic-latency-echo | ||
|
|
7ec3107b4a |
feat(client): the mic has an off switch, and echo cancellation has a switch too
Ctrl+Alt+Shift+V mutes and unmutes the microphone mid-stream — V for
voice, since M and S were taken. The uplink keeps running while muted:
`MicStreamer::spawn` takes a shared AtomicBool the capture callback reads
every quantum, and a muted callback drains whole frames and sends
nothing. Stopping the stream instead would have re-primed the device
buffers and, on Linux, re-run source selection on every unmute — a
second of glitch for a key people press mid-sentence. The sequence
counter deliberately does NOT advance while muted, so the host sees one
continuous sequence with a pause rather than a gap the size of the mute,
which its de-jitter would try to conceal frame by frame (its 600 ms
stale-flush covers the rest).
The mute lives on SessionHandle as a MicControl with two flags, not one:
`live` is raised by the pump only once the uplink is actually running, so
a session with the mic off in Settings — or whose capture device wouldn't
open — reports "nothing to mute", the chord says so in the log, and no
indicator appears. Per session, never persisted.
Muted state draws as a persistent "Microphone muted" badge in the stream's
top-right corner, off `FrameCtx::mic_muted` rather than the stats text: it
has to be there with the stats overlay Off, which is where most people
leave it. The Detailed mic line still reads throughput, so it simply falls
to zero — the badge is what answers "am I muted".
Echo cancellation stops being an env-only lever. `Settings::echo_cancel`
(default on, `#[serde(default)]` so every stored file loads with it on)
now gates the same hooks PUNKTFUNK_NO_AEC gated: the echo-cancelled
PipeWire source preference and WASAPI's Communications stream category.
The env var still wins, one-way — it can only turn AEC off, never back on
— and both `aec_enabled` helpers say so. The row ships in the GTK, WinUI
and console settings, under the microphone toggle and greyed out while it
is off, matching what Apple and Android shipped in wave 1.
SettingsOverlay grows `echo_cancel` as a first-class field — apply,
absorb, clear, is_empty — instead of riding the `extra` passthrough, where
`clear_override("echo_cancel")` answered false. The JSON key is the one
Apple and Android already write, so one catalog round-trips through all
three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
9f72a3b6ad |
feat(host): HDR and 4:4:4 stop being mutually exclusive on Windows
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m6s
deb / build-publish (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 33s
deb / build-publish-host (push) Successful in 5m4s
android / android (push) Successful in 6m40s
ci / rust (push) Successful in 7m10s
arch / build-publish (push) Successful in 8m11s
apple / swift (push) Successful in 5m8s
apple / screenshots (push) Canceled after 10m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
An HDR display cost you full chroma: the IDD-push capturer's only 10-bit
output was P010, so a session that negotiated 4:4:4 on an HDR desktop was
converted to 4:2:0 at capture time — *after* the Welcome had already told
the client 4:4:4. The client believed it (nothing on the wire contradicts
a Welcome), and the new chroma tag in the stats overlay is what finally
made the discrepancy visible.
Everything except the source was already in place, which is why this is
small: the NVENC config layer has stamped `FREXT` + `chromaFormatIDC=3` +
`pixelBitDepthMinus8=2` — HEVC Main 4:4:4 10 — with a unit test since the
4:4:4 work landed, `PixelFormat::Rgb10a2` already maps to `ABGR10` and
already counts as a full-chroma input, and the desktop client learned the
10-bit 4:4:4 Vulkan pool format in
|
||
|
|
253e0bbe7c |
feat(android): the mic has an off switch you can reach mid-stream
Wave 1 gave the Android client a mic worth using. It gave it no way to stop talking: leaving the stream, or digging through Settings to turn the whole feature off, were the only ways to stop the room being heard. Now a tap (or Select + Y on a pad) mutes it, and the screen says so while it lasts. How muting gates the capture, and why that way. The AAudio input stream is never stopped: a stop/start would re-run the input-preset fallback ladder and re-prime the buffers on every toggle — hundreds of milliseconds, and possibly a landing on a different rung, silently losing the HAL echo canceller wave 1 went to some trouble to get. Instead the encode loop reads an AtomicBool per 10 ms frame and, while it is set, drains the frame out of its ring and drops it there — the last point before it would have become an Opus packet. Nothing is encoded, nothing is sent, and the realtime capture callback is untouched, so its allocation-free discipline and the queue policy stay exactly as wave 1 verified them. A toggle costs one atomic store and takes effect on the next 10 ms boundary. The frame counter keeps advancing across a mute, because it numbers the captured 10 ms TIMELINE rather than the datagrams. The gap the host then sees is exactly the audio that never came: its de-jitter conceals at most a few frames of it before the pump's 600 ms stale-gap flush resets the chain outright, which is the right reading of a mute. Encoding silence instead would have kept a pointless uplink and a host-side ring alive for its whole duration. Mute is per session and nothing is persisted — a new stream always starts unmuted, and no new setting exists. The flag lives on the session handle rather than on the capture, so the mic stop/start a surface recreate performs brings the user's choice back with it, with no window in which the fresh capture could send an unmuted frame. The control is offered on the evidence that a capture is actually running (nativeMicActive), not on the setting: with the mic disabled, RECORD_AUDIO denied, or every AAudio input rung refused, there is nothing on screen to lie about. On touch it is a pill in the corner the stats HUD doesn't use — the one in-stream control, so it sits above the gesture layer to take its own taps — dim while live, a red "Muted" badge while it isn't. On TV that badge is the indicator alone: Select + Y is the control there, and a focusable button would fight the game for the D-pad. Y is deliberately not one of the exit chord's buttons, so neither chord can be reached through the other. One honest consequence of keeping the stream open: the platform's recording indicator stays lit while muted, because the mic really is still open. What stops is the encode and the send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
47eb8c9f6f |
fix(windows): a web-console deploy that half-succeeded stops reporting success
windows-host / package (push) Failing after 26s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 1m7s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 1m57s
ci / docs-site (push) Successful in 2m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-client-arm64 (push) Successful in 2m22s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
deb / build-publish-host (push) Successful in 4m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
deb / build-publish (push) Successful in 5m45s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 31s
android / android (push) Successful in 7m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m55s
arch / build-publish (push) Successful in 8m58s
ci / rust (push) Successful in 7m17s
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
release / apple (push) Successful in 16m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m53s
apple / screenshots (push) Canceled after 16m31s
Found by investigating a console that had been serving errors on .173 for
hours while every health check said HTTP 200.
Nitro's `entry.mjs` imports its sibling chunks by CONTENT HASH, so a
`.output` that mixes two builds is not degraded — it is dead: bun answers
every page with a `ResolveMessage` JSON body ("Cannot find module
../_/router-<hash>.mjs") under a 200 status. Two defects here let exactly
that ship and then hid it:
* The pre-copy `Remove-Item` used `-ErrorAction SilentlyContinue`, so a
removal blocked by a still-running bun was swallowed and `Copy-Item`
merged the new build into the old tree. (Reproduced live: an older
task-based copy of this script, run against the now supervised-child
host, tried `schtasks /end` for a task that no longer exists, never
stopped the service, and so could never unlock the files.) The removal
is now verified and refuses to copy over a tree it could not clear.
* The success probe read only the status code, so it reported a healthy
console for a server that serves nothing but an error. It now checks
that `/login` actually returns HTML, and says so loudly when the body
is a module-resolution error instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
766991cf6a |
feat(apple): the mic has an off switch you can reach mid-stream
Until now the only way to stop sending the room was to end the session and turn "Send microphone to the host" off in Settings — a per-app setting for something that is really a per-conversation act. The mic now mutes from inside the stream: a button on the HUD card, the Stream menu's ⌃⌥⇧A (macOS menu bar and iPad hardware keyboards), the same chord while input is captured (both platforms detect it in InputCapture, where the reserved ⌃⌥⇧Q/D/S already live — ⌃⌥⇧M is the mouse-model flip, cross-client, so the mic gets A), and on iPhone/iPad a mic disc beside the touch exit for the stats tiers whose HUD carries no buttons. Muting is local and instant: it gates capture on this device, the host is never asked and never told. The muted state gets its own badge over the stream — independent of the stats overlay, because "am I muted?" is not a statistic and the overlay is exactly what a player turns off. The badge is also the way back: tapping it unmutes, which is the guaranteed path for a touch user who muted with the overlay off. Mute is session state and is deliberately not persisted. Every stream starts live if the mic is enabled at all, rather than carrying a mute nobody remembers making into a call three days later. The mechanism is the one wave 1 built. `SessionAudio.setMicMuted` — which mutes the voice processor's input on the combined engine and pauses the capture engine on the split one — stays the single muting path; what changes is that it now takes an EFFECTIVE mute the session composes from its two reasons: the user's mute and the background keep-alive's privacy mute. Neither can clear the other, so a user who muted before pocketing their phone comes back still muted, and backgrounding no longer un-mutes anyone on return. It also latches the state, so a mute made while the microphone permission prompt is still open lands on the engine that grant creates instead of being lost. The control is offered only where there is something to mute: the session's resolved `micEnabled` (a profile can turn the mic on or off), a platform with an app-accessible input (never tvOS), and a TCC grant the OS hasn't refused. Absent rather than greyed on the HUD and the touch discs, greyed on the menu, and the macOS start-of-stream banner only teaches ⌃⌥⇧A when the session actually sends a microphone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
41e7035441 | Merge branch 'audio/apple-mic' into audio/mic-latency-echo | ||
|
|
27861b52b8 | Merge branch 'audio/android-mic' into audio/mic-latency-echo | ||
|
|
c846b165ae | Merge branch 'audio/core-desktop-uplink' into audio/mic-latency-echo | ||
|
|
d3870294d0 | Merge branch 'audio/host-mic-jitter' into audio/mic-latency-echo | ||
|
|
e861565e27 |
docs: "Why do I hear myself" — the four echo loops, and which knob stops each
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
79856d2c50 |
fix(host/audio): a VoiceMeeter box can no longer loop the mic into the stream
wiring_plan could hand the mic Voicemeeter Input and the loopback Voicemeeter Aux Input — two strips of the same internal mixer, i.e. a digital feedback loop with no acoustic path to break it, because leftover() never asked virtualish() and the exclusion list only knew "cable" and the Steam Speakers. Now every VoiceMeeter or generically "virtual" render is excluded from loopback, and the last-resort tier refuses anything virtual: a box with only mixer endpoints gets loopback=None, honest like the cable-only case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
efac33fd33 |
feat(host/audio): the virtual mic buffers for the client it has, not the worst one ever
The mic pump grows a real de-jitter (audio/mic_jitter.rs): a two-frame reorder window in front of the decoder, libopus concealment on sequence gaps (up to 5 frames — a lost datagram no longer drains the ring into a silence + re-prime crackle), and an adaptive target depth measured from inter-arrival jitter, clamped to 10–60 ms. Both backend rings now prime at one consumer quantum + that target: the old bursty Mac client still measures ~42 ms and lands where the fixed 48 ms prime protected it, a modern 10 ms-cadence client settles at ~25–35 ms, and a 2048-frame recorder on Linux stops buying 128 ms of latency from the 3-quanta clamp. Depth stuck above target sheds near-silent frames a few ms per 100 ms — never speech, never a hard clear. PUNKTFUNK_MIC_LEGACY_BUFFER=1 (documented) is the one-release escape hatch back to the fixed constants, and a "mic uplink health" line every 30 s (depth/target, cadence, gaps, conceals, reorders, drops, re-primes) finally says which side of the link a bad mic lives on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2ce0bea830 |
feat(client): desktop phase-locked capture, a real display volume on Windows, and the console's missing rows
The cross-platform half of the gap sweep: * Phase-locked capture reaches the DESKTOP clients (it shipped on Apple/Android only, though the desktop presenter has the best latch signal of all — true on-glass stamps via VK_KHR_present_wait). The presenter's 1 Hz fold publishes a latch grid (anchor = last on-glass instant; period = min positive present spacing, capped by the display mode's refresh so an arrival-paced sub-panel-rate stream can't claim a slower grid); the session pump folds every AU's arrival stamp against it with the SHARED `phase::circular_latch` statistic and sends the ~1 Hz PhaseReport (1 ms uncertainty — reference-client parity). The cap is advertised only when present timing is real (`VulkanDecodeDevice::present_timing` gates `SessionParams::phase_lock`), and the host's applied grid offset from the 0xCF tail is logged so an on-glass run can watch the controller engage. * `Hello::display_hdr` stops being hardcoded `None`: Windows reads the panel's colour volume from DXGI (`IDXGIOutput6::GetDesc1`, the `--window-pos` output else the primary, advanced-color outputs only, gated on the HDR setting) so the host's virtual-display EDID matches the real glass. Linux keeps the EDID defaults — no portable Wayland/X11 query exists — and the comment now says exactly that. * The console settings screen (the ONLY editor in Gaming Mode) learns the rows it was missing: render scale, full chroma 4:4:4, invert scroll, capture system shortcuts, fullscreen-on-stream, auto-wake and the game-library toggle. * A spec-run session's device picks (GPU adapter, speaker, microphone) now come from the `--resolved-spec` instead of a raw Settings load — the last store read the spec path still owed (§5), and what would make those fields profileable. * `session_args()` documents why the GTK/CLI spawners pass no `--window-pos` (Wayland exposes no global coordinates to read and SDL can't apply them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fd75e66041 |
feat(windows): the shell spawns from the shared brain, and links, tests and pairing stop losing things
Windows-shell parity gaps from the 2026-07-31 sweep, closed:
* The spawner goes through the shared brain: `ConnectPlan::for_target` →
`session_args()` + a written `--resolved-spec`, replacing the
hand-assembled argv that meant Windows sessions ALWAYS took the compat
path (re-resolving every setting from the stores — the drift
orchestrate.rs documents as a trap) and that any field added to the
spec was silently Windows-dead. Fullscreen now comes from the plan's
effective settings (profile-aware) instead of a caller argument, the
spec temp file is cleaned up at exit, and the reader finally parses
the `{"window":…}` line so the SPAWNER persists the match-window size
(§5) instead of the renderer's load-modify-save fallback.
* A deep link to an unpaired host runs the trust ceremony instead of
refusing: the Pair screen opens seeded with what the link CLAIMED —
name shown as claimed, fingerprint carried, and the launch/profile
surviving the detour (`Target` grew a `launch` field for exactly
this). GTK parity; a shared link was a dead end here before.
* The speed test learns the Ask tier: a bound host whose profile
INHERITS bitrate now offers both "Set as default" and "Set in
profile" instead of silently creating a profile override. The global
write (and the forwarded-controller handler) also rebase on the file
before their whole-struct saves — two more stale-snapshot writers.
* The Controllers card shows the detected-pad inventory (read-only,
with the Steam-Input-virtual note) — the fastest answer to "is my
controller even detected?", present on GTK all along.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f3e122b0d0 |
feat(apple): the mic hears you, not the game — voice processing joins the engines
The Apple client on a loudspeaker was the primary reported echo source: two AVAudioEngines by design (arbitrary mic/speaker pairs, two clocks) meant no unit ever saw render and capture together, which is exactly what setVoiceProcessingEnabled needs. With the mic enabled and the new "Echo cancellation" toggle on (both defaults), playback and capture now share ONE engine with the system voice processor engaged — AEC, noise suppression and AGC — and the input node's other-audio ducking pinned to .min with advanced ducking off, because this is a game stream, not a call: the host's audio must never dip under the outgoing voice. The two-engine path survives where it is the right answer: echo cancel off, macOS sessions with a hand-picked speaker/mic UID or input channel (the voice processor only follows the system default devices, and its capture side is its own mono mix — a per-channel pick can't survive it), and any machine whose voice processor refuses to engage. Every failure falls back to a working configuration rather than silence. The capture chain reads the input format after the processor is enabled, so its mono/lower-rate output flows through the existing converter unchanged; a first-run permission grant swaps the playback-only engine for the combined one in place, reusing the same jitter ring and drain thread; background mic muting mutes the processor's input instead of pausing the shared engine (playback keeps running). echo_cancel rides the full settings plumbing micEnabled has — defaults key, effective settings, profile overlay (serialized as `echo_cancel`; the Rust catalog carries it via unknown-key passthrough until it grows the field), a captioned toggle beside the Microphone row, and a gamepad settings row. tvOS behavior is untouched (playback only, as before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e54258b8ac |
feat(apple): the mic uplink drops to mono 10 ms packets and stops shoveling its FIFO
The capture path carried three latencies that had nothing to do with the network: a 2048-frame tap request (42.7 ms bursts before the first sample could even be sliced), 20 ms Opus framing, and a mono signal duplicated into both stereo channels because the encoder was configured like the downlink. CoreAudio's Opus encoder takes mFramesPerPacket=480 and mChannelsPerFrame=1 just fine — probed empirically: the converter truly emits 10 ms mono CELT packets (TOC config 30), one per 480-frame chunk, and the stereo-shaped decoder upmixes them with the tone intact — so the uplink now asks for 10 ms tap buffers and encodes 48 kbps mono 10 ms packets, with 960 kept only as an init-time fallback. The host decodes any Opus frame ≤120 ms, so nothing changes on the wire's far end. The tap thread also stops burning cycles per callback: the mono fold and resampler scratch buffers are allocated once (regrown only if a larger device quantum ever arrives), and the chunk slicer walks a head index instead of removeFirst — which memmoved the entire backlog for every packet on a render-adjacent thread. iOS additionally asks the session for 5 ms IO quanta at 48 kHz when the mic is on (best-effort; the hardware decides). Verified: swift build (macOS arm64), swift test OpusCodecTests + AudioChannelFoldTests, and an iOS arm64 cross-build; the 480/mono behavior confirmed by TOC inspection on macOS 15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a681de7e5c |
feat(android): the mic goes through the echo canceller, and the host stops hearing itself
The capture stream opened under AAudio's default VoiceRecognition input preset, which deliberately bypasses the HAL's acoustic echo canceller — so a phone playing the game audio out of its own speaker fed that audio straight back to the host. Two layers fix it, both behind a new "Echo cancellation" setting (default ON, next to the Microphone toggle in the touch and console settings, per-profile like every tier-P setting): - Native: the mic opens under the VoiceCommunication preset (HAL AEC/NS on the capture path) and allocates an audio session id. The open ladder is Exclusive+voice → Shared+voice → Exclusive → Shared — some HALs refuse the preset or a session id outright, and a mic without echo cancellation still beats no mic; the last rungs are exactly the preset-less open this always did. - Kotlin backstop: nativeStartMic now returns the allocated session id (0 = none), and StreamScreen hangs the Java AcousticEchoCanceler + NoiseSuppressor off it (guarded by isAvailable), releasing them on every mic-stop path — the surface teardown and the final dispose — so a surface recreate re-attaches instead of leaking effect engines. The playback stream is untouched: retagging it voice/communication would route it through the phone-call chain and regress quality. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6286f91f89 |
feat(host/audio): the mic uplink keeps its sequence, and a backlog heals itself
The 0xCB datagrams always carried a seq + pts, and the ingest threw both
away one line after decoding them — the pump saw an anonymous byte pile.
Frames now travel as MicFrame {seq, pts_ns, opus} so the de-jitter that
follows can reorder, conceal and measure.
The shared queue also stops being a latency reservoir: cap 64 → 12, and
a pump that wakes to a backlog deeper than 6 frames jumps to the newest
4 instead of replaying the pile — before this, a scheduling stall could
park up to 1.28 s of standing mic delay that only a >600 ms silence gap
ever flushed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
55e01b4dcb |
feat(client): the stats overlay learns whether your mic frames arrive
NativeClient::mic_stats reaches the per-second stream window: sent and dropped (queue-full + stale-shed) frame deltas join the tracing line and the Stats event, and the Detailed OSD tier renders a mic line while the uplink is live — a healthy 10 ms-frame mic reads ~100 f/s, and a drop term means the client is shedding backlog, not the network eating audio. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d17e942db0 |
feat(client/audio): the mic speaks 10 ms mono and asks the OS to cancel the echo
Uplink format on both desktops: 10 ms mono frames (was 20 ms stereo), Opus Voip at 48 kbps with in-band FEC against 10 % assumed loss — half the frame-fill latency, half the samples, and a lost datagram's audio now rides in its successor. One datagram per frame, unchanged wire. Linux: the capture stream finally asks for its own quantum (NODE_LATENCY 480/48000) instead of inheriting the graph's 1024-2048 sample bursts, and when the user picked no mic it prefers an existing echo-cancel source over the default (PUNKTFUNK_NO_AEC=1 opts out; loading module-echo-cancel ourselves needs a load_module the pipewire crate doesn't expose yet). Windows: the capture client declares AudioCategory_Communications before Initialize so an endpoint's communications APO (the system AEC) can engage; capture stays stereo via autoconvert — the proven path — and downmixes to mono in code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a7d4213778 |
feat(android): the mic uplink drops to mono 10 ms frames, and stops hoarding stale audio
Latency, three ways, all inside mic.rs: - 48 kHz stereo 20 ms becomes mono 10 ms: speech gains nothing from a second channel, the shorter frame shaves a buffering interval off the uplink, and the host already decodes any Opus frame <= 120 ms with its stereo decoder (mono packets upmix) — no protocol change. The encoder follows: 48 kbps, complexity 5, in-band FEC at an assumed 10% loss so a dropped datagram reconstructs from its successor instead of a hole. - The latency ratchet is gone: the capture callback drops the NEWEST chunk when the hand-off channel fills, so an encode-side stall used to convert into standing mic delay that never drained. The encode loop now drains the whole backlog in one lump and, past ~60 ms, jumps to the newest ~20 ms (one audible blip, live again), counting what it shed in the periodic log line. The realtime callback stays exactly as allocation-free as it was. - The encode thread registers with the client's hot-thread set, so the ADPF session keeps mic encode on a fast core alongside audio decode. No .frames_per_data_callback() pin: AAudio's own docs say leaving it unset is the lowest-latency path (the callback runs at the device's optimal burst), and the encode side re-chunks to 10 ms frames anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
15392bd707 |
fix(core/client): the mic queue stops hoarding a second of your voice
MIC_QUEUE claimed 64 was ~320 ms of 5 ms frames; the frames were 20 ms, so it really allowed 1.28 s — and since a full tokio mpsc can only refuse the FRESH frame, one worker stall turned the whole backlog into permanent standing mic latency. The queue shrinks to 12 and the pump's mic task now sheds oldest-first past a ~60 ms backlog, so a stall costs a short dropout and heals itself. New per-stage counters (sent / dropped-full / dropped-stale) surface through NativeClient::mic_stats for the stats HUDs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
698925a036 |
fix(clients): settings saves stop reverting each other, Windows gets audio pickers, and a game link launches its game
The rest of the client-gaps handoff, plus what a parity sweep surfaced: * Every whole-file settings save rebases on the file first — the GTK dialog close and its two speed-test apply arms, the Windows per-control commit, the console screen. The file has five writers and no merge (profiles.rs documents the debt); saving a shell-lifetime snapshot silently reverted whatever another writer stored meanwhile, most visibly the spawner-persisted match-window size (item 4). * Windows honors the Speaker/Microphone picks: `audio_wasapi` grows endpoint enumeration (on its own MTA thread — UI threads are STA) and resolves PUNKTFUNK_AUDIO_SINK/SOURCE as endpoint ids, falling back to the default when the picked device is gone; the settings page gets the two rows (defaults scope, "(not detected)" like the GPU row). `speaker_device`/`mic_device` stop being Linux-only fields (item 5). * A punktfunk:// link with `launch=` opened a plain desktop session on Windows — the id was parsed, validated, planned, and dropped at the last hop. It now rides `initiate_launch` (plus a waking variant for the dial-first path). Linux always forwarded it. * The console UI offered "VAAPI" on Windows — a dead option there that also hid d3d11va, the actual Windows hardware path. The decoder list is per-OS now. * The Windows gamepad picker learns "Steam Deck" (the GTK picker had it; the host-side pad has always existed). * Doc fixes: gpu.rs named a nonexistent env var (PUNKTFUNK_ADAPTER → PUNKTFUNK_VK_ADAPTER), and session/Cargo.toml claimed video decode is Linux-only while explaining what the ARM64 leg drops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
74863c96b3 |
feat(client): 4:4:4 can stay on hardware, and the overlay says what you actually got
Two halves of the same honesty problem (client-gaps handoff items 1+2,
done together as the handoff asked).
The presenter learns full chroma: `vkframe_plane_views` accepts the
2-plane 4:4:4 pool formats (8-bit, and the 10-bit 3PACK16 sibling) —
what NVIDIA's Vulkan Video reports for HEVC RExt decode — with the
accepted set extracted into `vkframe_plane_formats` and pinned by a
decision-table test. The CSC shader needed nothing: its 4:2:0 siting
correction already self-disables when the plane widths match. The VAAPI
leg gets the same treatment (NV24 in `drm_fourcc_for` and the dmabuf
import, full-size chroma plane), and the Vulkan decoder's sw-format
gate admits NV24/P410. 3-plane 4:4:4 stays rejected — it needs a third
CSC binding — and demotes cleanly like every other unsupported format.
Design call (the handoff's fork, argued here as requested): (B)+(C),
not (A). No capability probe gates VIDEO_CAP_444 — software decode is
the guaranteed display floor on both OSes (swscale → RGBA), the decoder
ladder demotes on its own, and a probe-gated bit would turn the switch
inert on exactly the boxes that rely on the fallback. What (A) wanted
from a prediction, the overlay now delivers as ground truth:
The Detailed tier prints the encoder's target next to the measured
rate — `19.4 Mb/s · target 20 Mb/s (auto)` — and the resolved chroma,
`4:4:4→4:2:0` when the host declined the ask (mirroring `HDR→SDR`).
The target is live: `NativeClient::current_bitrate_kbps()` mirrors
every BitrateChanged ack, so an Automatic session's ABR re-targets are
visible as they move. This is the figure whose absence let the
settings-drop bug (
|
||
|
|
5926306a4c |
feat(windows): the web console becomes a supervised child of the host service
windows-host / package (push) Failing after 22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
ci / docs-site (push) Successful in 1m47s
ci / web (push) Successful in 1m53s
ci / rust-arm64 (push) Successful in 1m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
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 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
apple / swift (push) Successful in 4m45s
deb / build-publish-client-arm64 (push) Successful in 3m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish (push) Successful in 5m26s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m22s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Successful in 7m10s
deb / build-publish-host (push) Successful in 5m27s
arch / build-publish (push) Successful in 8m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m16s
apple / screenshots (push) Successful in 20m19s
Three silent console outages in one week (0x1 / 0xFFFFFFFF / 0x41306), each a different proximate cause of the same structural defect: the console's lifecycle was owned by Task Scheduler — one best-effort start per boot/logon/install, no retry on a plain non-zero exit, no watchdog — while the product already shipped a real supervisor. The service now supervises the console as a second child slot: plain session-0 spawn (suspended → own no-breakaway kill-on-close job → resume), started only once the host has written mgmt-token + cert.pem + key.pem (the cert race dies by construction), secrets read from their files at every respawn, bun's stdout finally captured in logs\web.log, doubling backoff 0.5s→60s that never gives up. Session switches never touch it; a service stop takes it down via the job. The PunktfunkWeb task is retired: web setup slims to password + legacy task delete + firewall, the 127-line web-run.cmd batch supervisor is deleted, an [InstallDelete] entry reaps the stale copy, and service install now sets SCM crash-recovery actions (restart 1s/5s/60s) since the console rides on the service process. StopBunRuntimes stays for the scripting runner + the one migrating upgrade. Design: punktfunk-planning design/windows-web-console-lifecycle.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9c5af8d7e1 |
fix(clients): the settings you chose reach the stream again, and four more that never did
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 2m50s
ci / rust-arm64 (push) Successful in 2m55s
deb / build-publish-host (push) Successful in 4m16s
deb / build-publish-client-arm64 (push) Successful in 4m56s
apple / swift (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 6m32s
deb / build-publish (push) Successful in 3m44s
android / android (push) Successful in 8m2s
ci / rust (push) Successful in 11m18s
arch / build-publish (push) Successful in 10m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m1s
apple / screenshots (push) Successful in 20m36s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m9s
Since spec mode (`f32c3aaa`, shipped in 0.22.0) the GTK shell has handed the session a `--resolved-spec` built from `Settings::default()`. A session running from a spec performs ZERO store reads by design, so every stream since has run at the defaults: `bitrate_kbps: 0` reaches the host, which reads it as its 20 Mbps fallback — the field report this starts from, "my bitrate seems to be stuck at 20 MBPS" on 0.22.3 — and with it the resolution, refresh, render scale, codec, decoder, HDR, 4:4:4, audio channels, mic, touch/mouse mode, invert scroll, stats tier, match-window and gamepad type. Profiles never applied at all: the spec's `profile` was None, and spec mode ignores `--profile`. The tell in the wild is that the GPU and audio-device pickers kept working — the session reads those three off disk into env vars before it reads the spec. The plan carried a comment explaining that `settings` "carries only what the argv needs (the fullscreen flag)". That was true when it was written and stopped being true one commit later, in another file. `ConnectPlan::for_target` answers that shape of bug: a front-end holding its own request type resolves through the same helper the session's compat path uses, so there is no hand-built `Settings` left to go stale. The GTK shell's `fullscreen_on_stream` parameter goes with it — that is a tier-P field, and a shell-read global was beating a profile that set it. The Windows shell was never affected (it spawns with no spec) but had the same fullscreen-vs-profile bug, so it moves onto the resolver too. The audit that followed found four more settings stored, rendered in two UIs, and read by nothing: - `enable_444` never became `VIDEO_CAP_444`. "Full chroma (4:4:4)" is announced in the 0.22.0 notes for Linux and Windows and did nothing on either; only Apple advertised the bit. The host already gates it on its own policy, its capturer, HEVC and a real GPU probe, and answers the resolved chroma in the Welcome before we build a decoder — the client only has to ask. There is no desktop decode probe (Apple has `Stage444Probe`), but the presenter bails cleanly on a plane format it doesn't know and demotes to software, so the downside is a slow decode, not a broken one. - `session_params` picked the HEIGHT fallback off `settings.width == 0`. - `cli::exec_session` dropped `--profile` from its forward list, so `punktfunk --connect … --profile Work` from a script or a Decky wrapper streamed with the host's binding instead. - `ResolvedSpec::write_temp` named the spec by the SPAWNER's pid alone, so a cancelled connect and the retry behind it shared one path — and the first child's exit deleted the file the second was still starting up to read. The regression test asserts the plan carries the profile-overlaid bitrate and that the spec equals it. On the old code both are 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |