Commit Graph
9 Commits
Author SHA1 Message Date
enricobuehler eb9203b115 fix(client/settings): a byte order mark stops silently erasing every setting
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m27s
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m56s
android / android (pull_request) Canceled after 2m30s
ci / rust (pull_request) Canceled after 2m17s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m8s
`Set-Content -Encoding UTF8` writes a UTF-8 BOM, and every Windows how-to
reaches for it, so `%APPDATA%\punktfunk\client-windows-settings.json` edited
from a shell arrives with `EF BB BF` in front of the `{`. serde_json rejects
that at byte 0 — correctly, JSON has no BOM — and
`.and_then(|s| serde_json::from_str(&s).ok())` turned the refusal into
`Default`. Every setting in the file, gone, with the file plainly correct on
screen and not one word anywhere about why.

Cost an hour on 08-07: a `codec: "av1"` edit was ignored and the client
negotiated HEVC. The obvious suspects — the negotiation, the caps, the host —
were all working exactly as designed.

So the mark is stripped, which is what every other JSON consumer on Windows
does. But the BOM is only the instance; the bug is the `.ok()`, which hides a
trailing comma, a truncated write and a hand-edit typo just as completely.
Those now cost one `warn!` naming the file and serde's own line and column. A
file that cannot be READ at all is reported too, and for the same reason: PowerShell's
`-Encoding Unicode` writes UTF-16LE, `read_to_string` rejects it as invalid
UTF-8, and that lands in exactly the same hole.

The RESULT is deliberately unchanged — `Default`, never an error. Nothing about
streaming may hinge on a settings file being readable, and refusing to start
because one is malformed would be a worse failure than the one being fixed. A
missing file stays silent, because that is just first run.

All three of this client's JSON stores share the loader, because all three had
the identical line: the settings file, the known-hosts store (where a BOM
silently unpairs every host) and the profiles catalog.
2026-08-07 17:45:38 +02:00
enricobuehler 1db7058a5d feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:46:27 +02:00
enricobuehlerandClaude Opus 5 5f55fa874a feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows
session client presented arrival-paced with no pacing layer at all: two depth-2
newest-wins hops into a drain-to-newest and an immediate present. That IS the
lowest-latency intent, but it was unnamed, unselectable, and had no alternative
— and on a surface without MAILBOX (AMD's Windows driver offers none, and any
compositor holding images does the same) the swapchain's own FIFO becomes a
standing queue worth a measured 11-13 ms at 60 Hz.

WP1 — the settings cluster, under the keys the Apple client already writes into
the shared profile catalog (present_priority / smooth_buffer / vsync /
allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but
never applied. PresentPriority::resolve mirrors the Android reference exactly
(anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes
2), so a profile authored on any client means the same thing on all of them.
Only the first two are consumed here; vsync/allow_vrr land in WP3.

WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests):
- FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity,
  drop-oldest overflow, and an underflow that re-arms the preroll (repeat by
  omission) — the Apple/Android semantics, with qDrop/qDry counters.
- LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps,
  min positive spacing capped by the mode refresh (measured, never queried —
  VRR and Android's per-uid refresh lie both punish trusting a reported rate).
  It now also publishes the host-facing LatchGrid, so the phase-lock report and
  the local scheduler cannot disagree about the grid.
- PresentGate: one undisplayed present in flight on FIFO surfaces, with the
  100 ms stale force-open. This is the standing-queue killer, and it is inert
  on MAILBOX/IMMEDIATE and without present timing — where behaviour stays
  byte-for-byte the shipped arrival pacing.

Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and
gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the
event timeout; smoothness serves one frame per latch slot and tightens the
loop's wait to that deadline; the adaptive slot margin starts at 0 and widens
+500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure
display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field
A/B without a rebuild.

PyroWave collapses smoothness to latency for the stream: its plane-ring
retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra
frames make buffering moot anyway.

Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target
cannot print a vacuous Finished): clippy -D warnings across pf-client-core,
pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:00:50 +02:00
enricobuehlerandClaude Opus 5 b297542c4d feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.

New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.

The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.

Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.

Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:21:26 +02:00
enricobuehlerandClaude Fable 5 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>
2026-07-31 23:07:30 +02:00
enricobuehlerandClaude Opus 5 b0ea1e6b51 fix(client/linux): the override marker appears on touch, profiles get a colour, and 4:4:4 gets a switch
Three things found by actually driving the client.

**The marker didn't appear until you reopened the dialog.** It was rendered once, at build
time, from the stored overlay — so changing a setting inside a profile looked like it did
nothing. The design says touching a control creates the override and the marker appears
immediately, and it has to: a user who changes a row and sees no acknowledgement has no
reason to believe it took. Every profileable row now builds its dot and reset hidden, and
the same handler that records the touch reveals them. Resetting a row touched in the same
sitting also un-touches it, so the commit can't re-write what the reset just removed.

**Profiles had no colour.** `accent` has been in the schema since P0 and nothing could set
it, which left every chip the same grey — and telling profiles apart at a glance across a
grid is the entire reason chips exist. Creating a profile now picks a colour in the same
breath as its name (hunting for it afterwards is what leaves them all grey), an existing
profile has a Colour row, and host-card chips are tinted with it. A palette of eight rather
than a free picker: legibility across light and dark is the job, and the schema still
accepts any `#RRGGBB` a hand-edit or a future picker writes. Anything that isn't `#RRGGBB`
is refused rather than interpolated into CSS, and each distinct colour registers one
display-wide rule (per-widget providers are gone since GTK 4.10).

**4:4:4 had no switch anywhere but Apple.** `VIDEO_CAP_444` has been on the wire for a
while with only `punktfunk-probe`'s env var to set it. It is now a setting — and a
profileable one, which is the point: full chroma is what makes small text and thin UI lines
crisp, so a "Work" profile wants it where "Game" usually doesn't. The host still gates it
on its own policy, HEVC, and a GPU that can actually encode it; advertising only says "I
can decode this and I want it".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:38:53 +02:00
enricobuehlerandClaude Opus 5 ad99be2fb2 feat(client): the overlay learns absorb + clear, so per-control shells can edit profiles too
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m47s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
ci / web (push) Successful in 1m8s
apple / swift (push) Successful in 5m16s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 9m5s
ci / bench (push) Successful in 5m34s
decky / build-publish (push) Successful in 21s
ci / rust-arm64 (push) Failing after 8m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / rust (push) Failing after 9m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-host (push) Successful in 10m37s
deb / build-publish-client-arm64 (push) Successful in 9m38s
arch / build-publish (push) Successful in 17m48s
deb / build-publish (push) Successful in 13m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m9s
flatpak / build-publish (push) Failing after 9m27s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m9s
Groundwork for the Windows half of P1, and a dedup of the Linux one.

The two shells commit differently: GTK writes once when the dialog closes (so it can hand
over a list of touched rows), WinUI writes on every control change (so it can't). `absorb`
serves the second shape — give it the effective settings before and after one control
fired, and it records the field that moved.

That is deliberately NOT the diff-on-save the design rejects. The comparison is against
the EFFECTIVE settings, i.e. what the control was actually showing, not against the
globals — so setting a value back to whatever the global happens to be still records an
override, which is exactly the pin the design wants. It only ever adds; removing an
override stays an explicit, separate operation.

`clear` is that operation, keyed by the overlay's own field names, with `resolution` as
the one alias for the width/height/match-window tri-state a single control drives on every
client. The GTK reset path now calls it instead of carrying its own `match` — that second
list was already a place where the two could drift, and the next client would have made it
a third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:26 +02:00
enricobuehlerandClaude Opus 5 3350d3cfbf feat(client): the punktfunk:// grammar — one parser, one emitter, one vector file
D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/<uuid>` for a
while; this is the same grammar, generalised and given a Rust implementation the GTK and
WinUI shells, the session and the coming CLI all share:

    punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]

The rule the grammar exists to keep is that a URL may only ever do what a click on an
existing card could do, minus trust decisions. So it carries REFERENCES to things that
already exist on this device — never values: no resolution, no bitrate, no codec, so a
web page cannot shape a session beyond picking among the user's own configurations. And
`pair` is not a route: it parses, and refuses, so a link can never start a trust
ceremony. Everything hostile is rejected here, once, for every front-end — 2 KB total
cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is an
error, not a shrug), control characters refused after decoding, launch ids held to the
charset the host and Decky already agree on, and `fp=` that must actually be 64 hex.

Resolution follows the same order everywhere: stable record id → unique host name →
`addr[:port]`, with `host=`+`fp=` as the reinstall recovery path, which is why
self-emitted links carry all three. An ambiguous name refuses rather than guessing; a
stale id with no recovery address refuses rather than dialling "11111111-…" as if it
were a hostname; an unknown-but-plausible address becomes the confirmation sheet, never
an auto-connect.

`pf://` parses as an input alias and is never emitted or registered — claiming a
two-letter scheme on MSIX/Apple is unconditional squatting, and a link that resolves on
one platform only is a trap.

The 44-case `clients/shared/deeplink-vectors.json` is the cross-language contract: this
module's tests run every case, and the Swift and Kotlin ports will run the same file, so
three parsers cannot drift into three different security postures. Refusal codes are
part of that contract, not just the happy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:32:32 +02:00
enricobuehlerandClaude Opus 5 a2be3f4de9 feat(client): settings profiles — the override catalog, the one resolver, and the session flag
A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.

A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.

The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.

Resolution has exactly one implementation, `trust::effective_settings()`:

    effective = overlay(profile).apply(global)
    profile   = one-off pick ?? host binding ?? none

Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.

Also here, because this effort touches every one of these anyway:

- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
  id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
  pins, the clipboard decision, the id — against refreshes that carry none of it.
  `clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
  hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
  — i.e. silently resets every setting the user has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:22:33 +02:00