P1's core on GTK (design/client-settings-profiles.md §5.1). The same surface now edits
either layer: a scope switcher at the top of General swaps between "Default settings" and
one profile's overrides. Deliberately not a second editor — a parallel one would drift
from this one field by field, which is exactly what the "one settings UI" principle is
there to prevent.
Three decisions carry the design's semantics into this file:
- Rows always show the EFFECTIVE value — the global underneath with the profile's
overrides applied — so a row the profile doesn't touch reads as the live global. That is
what "inherit by default" has to look like.
- Overrides are recorded ON TOUCH, never by diffing values at save time. A profile may
therefore pin a value that happens to equal today's global and keep it when the global
later moves. The handlers that record this are installed after the seed pass, or opening
a profile would override every row in it.
- Only profileable rows render in profile scope. Auto-wake, the library toggle, the
decoder and GPU pickers, the audio endpoints, the forwarded-pad picker and the detected
pad list are facts about this device or this network, not about "Game vs Work" (§3's
tier G/H), so they simply aren't there.
Switching scope closes the dialog — which commits the layer being edited — and asks the
app to re-open in the new one, so there is a single code path that builds the rows and no
ambiguity about commit ordering. Both scopes read the controls through one shared closure;
the tri-state resolution row is the obvious trap if they ever diverged.
Create / Rename / Duplicate / Delete live in the switcher's group. Names are unique
case-insensitively and the prompt refuses duplicates in place rather than after the dialog
is gone. Delete counts what actually breaks — hosts that fall back to the defaults, pinned
cards that disappear — and leaves bindings dangling on purpose: they resolve as "no
profile" everywhere, and rewriting every host record here would be a second, racier source
of truth.
`PUNKTFUNK_SHOT_SETTINGS_SCOPE` puts the screenshot scene into profile scope, so both
halves of the surface are capturable. Verified on .21 under Xvfb: the switcher, the
per-profile actions and the tier filtering render, and the Statistics row seeds to the
Game profile's "Detailed" rather than the global's value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
D1's activation half (design/client-deep-links.md §4.1). The client already had a
`--connect` CLI, but nothing claimed the scheme, so nothing outside a terminal could
start a stream: no browser prompt, no xdg-open, no double-clickable shortcut.
The `.desktop` entries gain `MimeType=x-scheme-handler/punktfunk;` and `Exec=… %u`, and
the rpm scriptlet joins deb and arch in running `update-desktop-database` — an entry
nothing indexes claims nothing. (nix substitutes the Exec line by prefix, so `%u`
survives; flatpak's exported entry is renamed to the app id by the standard mechanism.)
In the app, `HANDLES_OPEN` plus a positional-URL argv is the whole mechanism, and the
second half is the interesting one: argv stays withheld from GApplication as before
EXCEPT when it carries a URL, and then GIO's own single-instance forwarding delivers it
to the running window over D-Bus. Clicking a link with Punktfunk already open reuses that
window instead of racing a second instance, and we wrote no IPC to get it. A URL that
lands during a cold start (before the model exists) is parked rather than dropped —
silently losing a link is the one outcome that would make the feature untrustworthy.
Routing is four lines of translation, because the decisions are the brain's: parse,
resolve the host, resolve the profile, refuse. A link that resolves becomes exactly the
message a card click raises — the same wake, trust gate, and error surfaces — so there is
no second connect path to keep in sync. A link to a host we don't know, or know but never
pinned, opens the ordinary PIN ceremony seeded with what the link claimed, which is how
"a URL may never pair or trust on its own" stays true while still being useful. Never
preempting a live session is checked here, since only this layer knows one is running.
Verified on .21 under Xvfb against hand-authored stores: `connect/Bound%20Box?launch=`
resolved the host by name, carried the launch id through the trust gate, and spawned the
session; `punktfunk://pair/1234` and an unknown `profile=` both refused before any dial.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`packaging/windows/drivers/*` has run `deny(unsafe_op_in_unsafe_fn)` +
`deny(clippy::undocumented_unsafe_blocks)` for a while, with `forbid(unsafe_code)`
on the modules that need no unsafe at all. The main workspace had no lint config
whatsoever, so nothing stopped a clean crate from quietly growing an `unsafe`, and
nothing distinguished the handful of genuinely-unsafe lines inside a 600-line
`unsafe fn` from the safe ones surrounding them.
Three things, all mechanical:
* `#![forbid(unsafe_code)]` on the eight crates that already contain zero unsafe
(`pf-driver-proto`, `pf-host-config`, `pf-paths`, the three clean clients, both
tools). These were clean by accident, not by contract; now they are clean by
contract.
* `unsafe_op_in_unsafe_fn = "warn"` workspace-wide. `unsafe fn` states a contract
the CALLER must uphold — it was never meant to switch off checking for the whole
body. Measured fallout is 300 sites on Linux, and they are concentrated: six
files carry all of them, while `punktfunk-core`, `pf-frame`, `pf-clipboard` and
`pf-vdisplay` are already at zero. `warn` (not `deny`) so the build stays green
while those six are worked down; it flips to `deny` once they are. This is also
the Rust 2024 default, so it pays off the edition migration early.
* `proc::current_uid()` replaces eight `unsafe { libc::getuid() }` blocks. Each
site had copied out the same SAFETY note verbatim, which is the tell: `getuid()`
is parameterless, always succeeds and touches no memory, so there is no contract
for a caller to uphold and no reason for the unsafe to be visible eight times.
One `unsafe` behind a safe wrapper, none at the call sites.
Verified: `pf-vdisplay` builds clean on Linux (Nobara) at zero E0133; the
macOS-buildable crates build clean locally. No behaviour change.
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own
copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors
the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left
in each shell is the part that genuinely differs: its dialog or screen, its advert drain,
its re-key, and its route back into the trust gate.
Behavioural review, since a sleeping host to test against wasn't available:
- GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second
like every other implementation. Up to half a second slower to notice a host coming
back, and exactly the reference cadence in exchange.
- GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully
and takes the last, i.e. the freshest address. Same connect, fresher re-key.
- Windows sent one packet before the loop AND one on its first pass; the machine owns the
packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the
budget still expires at 90 s.
- Both keep their own cancel path, their own re-key, and their own error surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found reviewing the P0 wiring. The console builds its window and input models ONCE, from
the global defaults, and they live across every launch — but each launch now re-resolves
the host's profile. So a profile that sets `mouse_mode: desktop` on a host, while the
global stays `capture`, made the session advertise `CLIENT_CAP_CURSOR` ("this client
draws the host cursor itself") to a presenter latched in capture mode, which draws no
cursor: the host stops compositing the pointer and the stream has no visible cursor at
all — the exact failure the capability's own doc comment warns about.
The advertisement now follows the LATCHED mouse model, which is the one that is actually
true of the presenter. Everything the host is told about the stream itself — mode,
bitrate, codec, audio, pad — keeps honoring the binding, and the comment says which
fields are latched and why, so the console's presentation-tier gap is documented rather
than rediscovered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C0 of design/client-architecture-split.md. Wake-then-connect exists three times today —
GTK's `WakeConnect`/`wake_fallback`, the WinUI shell's `wake_and_connect` (whose comment
says it "mirrors the Apple HostWaker"), and Apple's `HostWaker` itself — and the
deep-link and profile work was about to make it five. `pf-client-core` already held the
ingredients (wol, discovery, trust, library, session); what was missing was the layer
that composes them, so every front-end composed them itself.
`ConnectPlan` is a resolved intent: which host, which pin, which launch id, which
profile, the effective settings, whether to wake. One constructor per door — a card
click, a CLI verb, a URL — one type out. `ConnectPlan::resolve` is pure (stores in,
plan out), which is what lets the URL router be tested without a config directory;
`for_host` is the convenience that loads them.
`plan_from_link` is where the deep-link security rules actually live, rather than in
each shell: a contradicted fingerprint refuses and says which host it was about, an
ambiguous name refuses instead of picking the first, a profile the catalog can't honor
refuses BEFORE anything is dialled, and an unknown — or known-but-never-pinned — host
becomes a confirmation sheet carrying the claimed name and expected pin, so the first
connect is verified rather than blind TOFU. Preempting a live session stays with the
caller: only the front-end knows one is running.
`WakeWait` is Apple's `HostWaker` cadence as a pure step function — packet at 0 s and
every 6 s, presence polled every second, 90 s budget, and a PARK (not an error) at the
end, because "it didn't wake in 90 s" is usually "give it 10 more". As a step function
each front-end drives it from its own loop and they still agree, and the whole cadence
is testable without waiting 90 seconds.
Session spawn, its argv and its stdout contract move here too, and the GTK shell now
spawns through them — so "which flags does a stream get" and "what does ready mean" have
one answer for the shells, the console and the coming CLI. `--profile` deliberately does
NOT ride a plain card click: the session resolves the host's binding through the same
helper, and passing it would be a second source of truth for one decision.
Not yet adopted: the two shells' wake paths. That swap changes the most-used path in the
product and the handoff wants it verified on glass with a genuinely sleeping host, which
this session couldn't do — so the state machine lands, the duplication stays until it
can be verified away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Every headless call went through `flatpak run io.unom.Punktfunk`, and the
launch wrapper exec'd the same. On a Deck whose client came from a sysext, a
.deb/rpm, an AUR build or a nix profile there is no such app: discovery
worked (it is mDNS), and everything that needed the client — pairing, the
library fetch, the host store, the stream launch itself — failed.
Resolve the client once. The flatpak still wins when it is actually
installed, so an existing Deck is byte-for-byte unchanged, and a native
`punktfunk-client` is the fallback; `PF_DECKY_CLIENT` forces one on a machine
with both. Both kinds share ~/.config/punktfunk — the flatpak's sandbox HOME
is the real home — so identity, known-hosts and settings need no divergence.
The wrapper takes the resolved binary as PF_CLIENT_BIN and execs it in place
of `flatpak run`; kill_stream sends a name-matched SIGTERM where there is no
flatpak instance to kill; and the client-update check, which reads the
flatpak's tracked remote, simply reports nothing for a native install, whose
updates belong to whatever installed it.
Installed-ness is now decided by the app's exported directory rather than by
the presence of the `flatpak` binary, so a machine that has flatpak but not
this app no longer resolves to a client that cannot run.
Closes#11
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three separate things an iPad's keyboard and mouse got wrong.
Every mouse button past the first two clicked LEFT on the host. UIKit's
button mask is 1-based over the HID order, and only `.secondary` was read —
middle, back and forward all fell into the `else` and became button 1. Map
3/4/5 through `.button(_:)`; only middle and right swap places, because the
wire numbers them the other way round.
Holding a key deleted exactly one character. GameController reports a key
once on press and once on release — it has no repeat channel — and the host
injects exactly what it is told, so nothing downstream ever turned a held key
into a stream of presses. Generate the repeat client-side at the usual
delay-then-rate, newest key only, with modifiers and lock keys excluded.
macOS was never affected: NSEvent hands it the window server's own repeats.
Scrolling ignored the system's Natural Scrolling switch. UIKit has already
applied that preference by the time it hands over a pan translation — it is
what makes every UIScrollView on the device turn the right way — so negating
y pinned the stream to traditional scrolling and inverted the setting for
everyone on the default. Pass the sign through, exactly as the macOS path
passes `NSEvent.scrollingDeltaY` through, and let that one recognizer own
scroll under pointer lock too: it is the only way trackpad two-finger
scrolling ever arrives, so gating it on the lock had been dropping it
entirely there, and the raw GameController axis it deferred to carries no
such preference. iOS installs no GCMouse scroll handler now, so nothing
double-sends; tvOS, which has no scroll pan, keeps it.
Closes#7, closes#15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not every mouse reports back/forward the same way. One that carries them on
the HID button page (BTN_SIDE/BTN_EXTRA) gets BUTTON_BACK/BUTTON_FORWARD in
the motion button state, which is the only shape the forwarder listened for.
A mouse that carries them on the consumer page (AC Back / AC Forward — the
common Bluetooth shape, and what Android TV boxes tend to see) produces only
synthesized KEYCODE_BACK/KEYCODE_FORWARD, and those were swallowed outright
to stop them doubling as Android navigation. On that hardware both side
buttons were simply dead.
Route the key shape into the same wire buttons, deciding by the DEVICE (it
has to be able to be a mouse) rather than by the event's source, since the
consumer-page collection is a separate sub-device that may be stamped
SOURCE_KEYBOARD. A D-pad-capable device is excluded so an air-mouse remote's
BACK stays the couch user's way out of the stream.
Both paths now funnel through one press/release helper whose held-set guard
collapses a device that reports BOTH into a single wire press.
Closes#8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shell is a windows_subsystem binary and spawns punktfunk-session with
CREATE_NO_WINDOW + inherited stderr: a normal GUI/MSIX launch has no
console, so every log line — the shell's and the session's whole
receive/decode/present forensic trail — evaporated exactly when a user
hit something worth reporting. The 2026-07 PyroWave latency-sawtooth
report had to be triaged from host logs alone; the deciding evidence
(clock re-sync vs backlog-flush lines) lives client-side.
- New logfile module: %LOCALAPPDATA%\punktfunk\logs\client.log,
the host's rotation convention (10 MB cap -> .old at next start, one
generation), best-effort (no dir -> plain stderr, never a startup
failure).
- The tracing subscriber tees to stderr AND the file (ANSI off — the
file is what users send); startup logs the path.
- The spawned session's stderr is piped through the same tee,
line-buffered — dev-terminal runs keep their interleaved output,
GUI runs finally keep anything at all.
- The --console path and the punktfunk-console.exe MSIX shim forward
the same way (a couch launch has no console either).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Emulation-and-beyond libraries need more than a title and a poster. Every
library shape (GameEntry, CustomEntry, CustomInput, ProviderEntryInput)
now carries a shared, flattened GameMeta: platform, description,
developer, publisher, release_year, genres, tags, region, players. All
fields are optional and flat on the wire, so existing library.json files,
provider plugins, and clients keep working unchanged.
- Installed-store scanners (Steam, Lutris, Heroic, Epic, GOG, Xbox) stamp
platform=PC; custom/provider entries carry whatever was authored.
- GET /library grows a ?platform= filter (case-insensitive) beside
?provider=.
- Console: the add/edit form gets a Details section (round-tripping every
field, since update replaces the entry), the poster tile a platform
badge (non-PC only — the store badge already implies PC) and the year.
- plugin-kit: ProviderEntry accepts the same fields (new GameMeta schema,
spread flat); SDK + OpenAPI spec regenerated.
- pf-client-core decodes platform for future client badges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pf_dualsense.inx` gave all four hardware ids a single %DeviceDesc%, so Device
Manager labelled an emulated DualShock 4, DualSense Edge and Steam Deck pad
"punktfunk Virtual DualSense". The HID layer was always per-type — device_type
picks the PID (09CC for DS4), the report descriptor and the product string — but
the one place a user goes to check said DualSense for every choice, which reads
exactly like the controller-type setting being ignored.
Split into four model lines over the same install section, one description each.
No binding, service or descriptor change; stampinf's 9.9.MMdd.HHmm DriverVer
increments on every build, so pnputil takes the update. InfVerif on the WDK
runner: INF is VALID.
Also correct the Slot.pref comment from the previous commit: emulating a
DualShock 4 gives up adaptive triggers by construction. HidOutput::Trigger is
emitted only by dualsense_proto, and a DS4 has no trigger-effect reports — the
host never generates any to send. Rumble and the lightbar remain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Controller type" setting reached the Hello and stopped there. Every pad then
opened with a GamepadArrival carrying its DETECTED kind, and the host builds each
virtual device from that arrival — the session default is only the fallback for a
pad that never declares one. So "emulate my DualSense as a DualShock 4" put a
DualSense on the host the instant the controller connected, and no amount of
reconnecting helped. Apple and the native clients both; Android already applied
the setting per pad.
The setting now rides the declaration too: explicit wins for every slot, Automatic
keeps per-pad detection so a mixed session stays honest. The physical kind still
drives the LOCAL feedback paths — a DualSense emulated as a DualShock 4 keeps its
lightbar and adaptive triggers, and a Deck keeps its rumble keep-alive.
Regressed in 97c67b26 / 76be4c3e (multi-controller support); ships in 0.19.x.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit,
and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case,
not a degenerate one, and it is not a reason to hide the pointer:
* State (0xD0) is a per-frame datagram and announces the new serial the moment
the host QUEUES the bitmap on the reliable control stream, so on every single
shape change the client knows a serial before it holds the pixels.
* The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the
host never re-sends it — it only sends on a serial CHANGE — so a dropped
serial stays un-backed until the pointer next changes shape. Crossing a
toolbar flips arrow/I-beam/hand/resize several times a second, and each flip
mints a fresh serial and a fresh bitmap, so bursts are ordinary.
Either way the pointer BLINKED OUT rather than lagging, and in the dropped case
it stayed gone for as long as the pointer held that shape — reported on glass as
"the I-beam never appears over text fields, every other cursor is fine".
Hold the last worn shape across the gap: only `st.visible == false` (the host
says the pointer is hidden) may hide it now, never a missing bitmap. Worst case
is a briefly stale pointer. This also makes two comments that already claimed
this behaviour true — the shape-rejected warning's "keeping the previous cursor"
and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction.
Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005,
CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct
glyph — black beam, white outline — so the bitmap that goes on the wire is good.
swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`punktfunk-session --pair <PIN> --connect host[:port]` runs the SPAKE2
ceremony with no window and no toolkit, prints the same
`paired <addr>:<port> fp=<hex>` line as `punktfunk-client --pair`, and
exits. Until now the PIN ceremony lived only in the GTK shell or the Skia
console, so enrolling an embedded/kiosk client meant installing a desktop
on it — or copying the identity store by hand.
Dispatches above every graphics call (the machine may have no display) and
is in the `--no-default-features` build: enrolling must never be the reason
a minimal image pulls in Skia. `--name` defaults to the hostname instead of
the desktop path's hardcoded "Steam Deck".
`forget_placeholder` and `device_name` move into pf_client_core::trust so
the two binaries share one implementation rather than the session
re-deriving them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Native sessions could survive long after the client was gone, in two
independent ways.
HOST: `stop` was only advisory. The one thing that ends a session is the
stream thread returning — every teardown (conn.close, the joins, and the
RAII drops of the session permit, admission entry and stream marker)
sits after that await, and nothing forced the issue. The encode loop
checks `stop` between iterations, so any unbounded call INSIDE one never
reaches the check, and one stuck syscall became a permanent zombie: it
held its semaphore slot (four of those and the host stops accepting
QUIC entirely), its admission entry (a later client gets "host busy"
forever), and not even the console's Stop button could clear it — that
button sets this same flag.
* Bound the wait: once the session has been told to stop, the thread
gets STREAM_STOP_GRACE (90 s, well past the 40 s capture-rebuild
budget) to return, then teardown runs anyway. The thread is detached,
not killed — Rust can't cancel a blocking thread — so it keeps its
capturer/encoder until the stuck call returns, but the session's slot
and admission entry come back and the host keeps serving. It logs at
ERROR as the host wedge it is.
* Bound the audio/input joins too — the last unbounded await in
teardown.
* Take the session permit AFTER the QUIC handshake instead of before
`accept()`, so a host at its concurrency cap still accepts and the
waiting client sees a live path instead of a silent dial timeout.
* Bound the compositor helpers that caused the wedge in the first
place: new pf-vdisplay `proc::{status_within, output_within}` kill a
child that outlives its budget. `kscreen-doctor` is a Wayland client
of the very compositor it configures, so against a wedged KWin it
never returned; same for systemctl/dbus against a stuck session bus.
CLIENTS: the connection was never closed, so the host was right to keep
the session — it still had a live, keep-alive-answering peer.
* Android: backgrounding did no teardown at all, and Android doesn't
suspend the process, so the worker kept answering keep-alives until
the OS reclaimed it (on a TV box, never). End the session on ON_STOP,
via the existing onDispose path; a plain close, not a quit, so the
host lingers the display for a fast return.
* Apple: the .background arm was iOS-only AND gated on an opt-in that
defaults off, so backgrounding did nothing — while the `audio`
background mode kept the app (and its connection) alive indefinitely.
Act unconditionally, and cover tvOS.
* Core: `conn.close()` only queues the frame, and run_pump is the body
of a block_on whose runtime is dropped the instant it returns, so the
driver could never put it on the wire — a deliberate quit reached the
host as silence (8 s idle timeout, no quit code, and the linger meant
for an unwanted disconnect). Carry the endpoint out of the handshake
and flush with wait_idle(), the same discipline the pairing and probe
paths already use.
Linux check/clippy/tests green: 262 host, 71 pf-vdisplay (incl. new
bounded-process tests), 231 core.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stream is not user activity to the OS, and controller input does not feed
the HID idle timer on any Apple platform — so a gamepad-only session reliably
idles the panel out from under the user mid-play. A keyboard/mouse capture
session masks the bug because those events are real local HID. The Android
client has held FLAG_KEEP_SCREEN_ON while streaming all along; the Apple
clients held nothing.
DisplaySleepGuard is acquired in beginStreaming and released at the top of
disconnect, so it is scoped to the session — every teardown path (user quit,
sessionEnded, the backgrounded keep-alive timeout) funnels through disconnect.
iOS/iPadOS/tvOS: UIApplication.isIdleTimerDisabled. App-wide and ignored
while backgrounded, which is what the audio-only keep-alive wants.
macOS: ProcessInfo.beginActivity(.userInitiated, .idleDisplaySleepDisabled),
the Foundation wrapper over IOKit power assertions. That defers display
sleep but NOT the screen saver, which runs off the HID idle timer
independently — so a 30 s IOPMAssertionDeclareUserActivity heartbeat runs
alongside it. Intended side effect: an idle-lock that follows the screen
saver is deferred for the session too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two Android TV M&K regressions from the c90343c2 overhaul, both user-reported:
1. Mouse back/forward buttons doubled as Android navigation while streaming.
The BUTTON_BACK/FORWARD motion edges already go over the wire as X1/X2, but
Android's input reader ALSO synthesizes KEYCODE_BACK/FORWARD key events from
those buttons unconditionally (stamped SOURCE_MOUSE, no FLAG_FALLBACK) — and
the guard only swallowed the FLAG_FALLBACK variant, so the duplicate reached
the BackHandler and yanked the user out of the stream. Swallow mouse-sourced
BACK/FORWARD too; a remote/keyboard BACK is never mouse-sourced and still
exits.
2. The soft keyboard popped up on physical-keyboard typing. KeyCaptureView (the
always-focused IME/capture anchor) unconditionally claimed to be a text
editor with a live editable InputConnection, so the TV IME counted input as
active and showed its UI the moment hardware keys arrived. Gate the editor
identity on imeShown (the user actually summoning the keyboard via gesture /
remote toggle), with restartInput on both edges so the gate is re-read, and
an onKeyPreIme BACK hook so an IME self-dismissal can't leave the gate
stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Android leg of design/pen-tablet-input.md §7: against a HOST_CAP_PEN host,
stylus/eraser pointers split out of BOTH touch models (passthrough + gesture)
into StylusStream — state-full samples with pressure, AXIS_TILT, azimuth from
AXIS_ORIENTATION (Android's 0 = away-from-user IS wire north), AXIS_DISTANCE
hover, both stylus barrel buttons, the eraser tool, and historical (coalesced)
samples batched oldest-first. Kotlin heartbeats ≤100ms per the wire contract.
JNI: nativeHostSupportsPen + nativeSendPen (flat 10-float stride, sentinels
<0). No barrel-roll axis exists on Android — roll stays unknown here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flagship leg of design/pen-tablet-input.md §7: against a HOST_CAP_PEN host
the Pencil splits out of the finger path (independent of touch-input mode) into
PencilStream — state-full samples with force→pressure, altitude→polar tilt,
azimuth (+90° to the wire's north-clockwise convention), Pencil Pro rollAngle→
barrel roll (17.5+), coalesced touches batched ≤8/send for full 240Hz fidelity,
and Pencil hover (zOffset>0 distinguishes it from trackpad hover) as in-range
samples with distance. Squeeze holds barrel 1, double-tap clicks barrel 2 (the
Pencil has no hardware buttons/eraser — these are how host apps get those
affordances). Implements the ≤100ms heartbeat wire contract (80ms timer) so a
stationary held stroke survives the host's 200ms dead-client failsafe. Toward a
pen-less host nothing changes (Pencil stays a finger).
Also fixes hostSupportsCursor testing the STALE 0x04 bit — HOST_CAP_CURSOR
moved to 0x08 when TEXT_INPUT claimed 0x04; the old test would mistake a
text-input host (e.g. Windows) for a cursor grant and double-render the pointer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Mac client drew the host-forwarded cursor bitmap at its native pixel
size (pixel-as-points), so the on-screen pointer tracked the HOST's display
scaling: a 4K/high-DPI virtual display renders a 96 px pointer, which then
appeared huge on the client — and on retina every pointer was already
2x-inflated and upscaled-blurry, since the backing scale was ignored.
Cache the shape raw and rebuild the NSCursor at the live video-fit factor
(fit.width / mode.width — the same AVMakeRect fit the input mapping uses)
on each apply, re-fitting on resize / retina moves. The pointer now renders
at its true size relative to the streamed desktop at any host scaling:
crisp and 1:1 with the video at 100%, and proportional (not ballooning)
when the host sits at 200-300% — including a deliberately raised scale.
iOS/tvOS (UIPointerInteraction) and Android (no forwarded-bitmap path)
render the pointer differently and need no change; the SDL/Linux client has
the same native-size issue and will follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The visible "Punktfunk" shortcut's Steam appId is cached in SteamUI
localStorage, which lives in Steam's CEF profile — so it survives a
plugin uninstall/reinstall. Deleting the shortcut left the key dangling;
on the next mount ensure*Shortcut recalled the dead appId, took the reuse
branch, and ran SetShortcut* against a non-existent id (silent no-ops).
AddShortcut was never reached, so the entry never came back. The same
stale-reuse also made the hidden stream shortcut RunGame a dead gameid.
Verify the remembered appId still maps to a live shortcut via
appStore.GetAppOverviewByAppID before reusing it; a confident miss falls
through to AddShortcut. When appStore is unavailable, assume it exists so
a false negative can't spawn a duplicate library entry.
Also add a "Recreate library shortcut" recovery button to the QAM About
section (recreateShortcuts): drops any dead cached keys and re-ensures.
Covers deleting the shortcut without reinstalling, where no mount fires
the self-heal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PunktfunkConnection.setCursorRender(clientDraws:) over the new
punktfunk_connection_set_cursor_render export, driven by one
edge-detected reconciler in StreamView (clientDraws = captured &&
desktopMouse) called from every transition — the ⌃⌥⇧M chord,
engage/release, and session start. Capture model and released now get
the host-composited pointer back in the video (full fidelity); the
desktop model keeps the local NSCursor path. xcframework rebuilt v12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cursorChannelActive/hostCursors/cursorState stayed latched across
sessions — a next session against a host WITHOUT the cursor cap would
wear the previous session's stale shapes via resetCursorRects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-glass (Mac ↔ Windows M2c host): the host cursor's VISIBILITY is not a
usable 'a game grabbed the mouse' signal — Windows hides the pointer for
ordinary desktop activity (clicking, typing). The per-frame forwarder
turned those transients into relative_hint=true, so the client flipped
desktop→capture→desktop, which (a) warped the cursor to view-centre and
(b) flushed held buttons via releaseAll — a spurious mouse-up ~200 ms
into every press that broke window dragging/resizing.
Disable the auto-flip; the mouse model stays user-driven (⌃⌥⇧M). The
hint still rides the wire, unused, for a future M3 that keys off a REAL
pointer-lock signal (host-side ClipCursor/raw-input detection) instead
of visibility.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Mac client now exercises the full cursor channel against capable
hosts: desktop-mode sessions advertise CLIENT_CAP_CURSOR, the host
stops compositing the pointer, and StreamView draws the forwarded
shapes as the real NSCursor — plus the M3 host-driven model flip.
- ABI v11: punktfunk_connect_ex9 (adds client_caps; ex7/ex8 pass 0 —
Android untouched), PunktfunkCursorShape/State + the two
next_cursor_* poll fns (audio-style borrow contract), and
PUNKTFUNK_CLIENT_CAP_CURSOR. Fixed in passing: the first insertion
split next_host_timing's cfg/no_mangle attributes off the fn, which
silently dropped the symbol from the header AND dylib — caught by
the Swift build, reattached with its docs.
- PunktfunkConnection: clientCaps connect param, hostSupportsCursor,
CursorShapeEvent/CursorStateEvent + nextCursorShape/nextCursorState
(one cursor thread drains both planes; cursorLock joins the close()
ladder).
- SessionModel: desktop-mode sessions (DefaultsKey.mouseMode) connect
with the cursor cap on macOS.
- StreamView: shape cache (serial → NSCursor, straight-alpha RGBA via
CGImage), resetCursorRects wears the HOST shape while the desktop
model is engaged (hidden host pointer ⇒ invisible), latest-wins
state pump, and the M3 auto-flip — edge-triggered on relative_hint,
⌃⌥⇧M sets an override latch cleared by the next host edge, leaving
relative warps the pointer to the host position via the inverse
letterbox mapping (CGWarpMouseCursorPosition, CursorCapture's
coordinate convention).
Verified: swift build + full test suites green (xcframework rebuilt at
ABI v11, signed); core 218 tests + clippy -D warnings on Linux (.21).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host cursor stops riding the video and becomes a real OS cursor on
the client (the Parsec/RDP model): pointer feel no longer pays the
capture→encode→network→decode→present round trip.
Wire (M2a):
- Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the
fixed display_hdr block — presence disambiguated by remaining length,
which caps the post-HDR tail at 27 bytes (documented); Welcome answers
HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent).
- CursorShape (0x50, control stream): serial + dims + hotspot + straight
RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot);
client caches by serial — re-showing a known shape costs 14 bytes, not
a bitmap (RDP pointer-cache for free).
- CursorState (0xD0 datagram): serial + visible/relative_hint flags +
position, sent once per encode-loop tick — latest-wins, self-healing
under loss, no refresh timer. relative_hint is reserved for M3.
- Client core: two new planes (control-task + datagram-task arms) →
next_cursor_shape/next_cursor_state; connect() grows client_caps
(C ABI passes 0 until the v11 cursor poll fns exist).
Host (M2b, Linux portal only):
- handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧
compositor ≠ gamescope) — Welcome bit and session wiring both read it.
- SessionPlan.cursor_blend goes false for a forwarding session; the
encode loop ticks a CursorForwarder every iteration: shape-serial diff
→ control-task bridge (mirrors probe_result), state datagram → conn.
- CursorOverlay/capture CursorState carry the hotspot through
(nearest-neighbor downscale backstop for XL cursors, unit-tested).
Presenter:
- CursorChannel drains both planes per loop iteration; shapes become
SDL color cursors (from_surface + hotspot), applied while the desktop
mouse model is engaged; visibility follows the host; capture/released
hands back the system cursor. Sessions advertise the cap when they
START in desktop mode.
Verified on .21: fmt + clippy -D warnings (7 crates) + tests green
(core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder
downscale tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Folds the parked client-side-cursor machinery (cursorMode auto/always/
never, hidden while disabled) into the cross-client mouse model:
MouseInputMode capture|desktop under DefaultsKey.mouseMode, picked in
Settings ▸ Keyboard & mouse (macOS), resolved at session start and
gated off on gamescope hosts (relative-only EIS).
Desktop model = the un-neutered absolute path with the SDL cursor
policy: pointer never disassociated (enters/leaves the stream freely),
monitor forwards letterboxed absolute positions, and the local cursor
hides only while over the view via an invisible-cursor rect (the
host's composited cursor is the one you see; AppKit manages the rect,
so no hide/unhide balancing). ⌘⇧C becomes ⌃⌥⇧M — the same chord as
the SDL clients — flipping the model live with an atomic
release/re-engage.
Verified: swift build + full test suites green (macOS arm64).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New physical-mouse model beside capture: Desktop leaves the pointer
uncaptured and sends absolute positions through the letterbox
(MouseMoveAbs — every host injector already consumes it). Capture
stays the default and the game model.
- pf-client-core: MouseMode (capture|desktop) persisted in Settings,
default capture so existing stores are unchanged.
- pf-presenter: Capture grows a desktop model — latest-wins pending
abs position coalesced per loop iteration (same 1000 Hz discipline
as relative), flushed before clicks/keys/wheel so they land where
the cursor is; Ctrl+Alt+Shift+M flips the model live; local cursor
stays hidden over the window (the host's composited cursor is the
one you see until the M2 cursor channel); Windows keyboard grab
only engages for capture (a desktop stream is something you
Alt-Tab away from).
- gamescope gating: its EIS is relative-only, so desktop mode is
pinned off there (resolved_compositor), with a log note.
- Settings surfaces: GTK row (dynamic caption), WinUI combo,
console-UI row + step test, capture-hint line.
Verified: fmt + clippy -D warnings + tests (33/40/19) on Linux .21;
clippy -D warnings for all five crates incl. punktfunk-client-windows
native on .173.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desktop clients' clipboard protocol, adopted on Android (text-only v1):
opt-in ClipControl at stream start when the host advertises HOST_CAP_CLIPBOARD
and the new "Shared clipboard" setting (default on) allows.
Device → host: local copies (primary-clip listener + a probe at start) are
announced as lazy format-list offers; the text crosses only when the host
actually pastes (FetchRequest → served from the live clipboard). Host →
device: a host copy's offer is fetched eagerly and lands in the system
clipboard (Android has no practical lazy-paste provider), with an echo guard
so the resulting clip-changed callback doesn't bounce it back as a new offer.
Native side: session/clipboard.rs JNI shims over NativeClient's clip_*
surface; events cross to Kotlin as compact strings from a blocking
nativeNextClip poll on a dedicated thread (the nativeNextRumble pattern),
joined before the session handle is freed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The VK key-event vocabulary cannot express text an input method COMMITS
(autocorrect, gesture typing, non-Latin scripts, emoji). Add a first-class
text event and negotiate it:
- punktfunk-core: InputKind::TextInput (= 15) carries one Unicode scalar per
event in `code`; HOST_CAP_TEXT_INPUT (0x04) in Welcome::host_caps.
- Host advertises the cap only where the session's inject backend can type
text: Windows SendInput (KEYEVENTF_UNICODE, surrogate-pair aware) and the
Linux wlroots backend — a dedicated second zwp_virtual_keyboard whose xkb
keymap grows Unicode keysyms on demand (the wtype model), so keymap
re-uploads never disturb the main device's layout/modifier state. The
KWin-fake-input/libei/gamescope backends can only press layout keycodes, so
those sessions don't set the bit and clients keep the VK fallback.
- GameStream plane: Moonlight's UTF-8 text packet (MAGIC_UTF8, previously
recognized-and-dropped) now decodes to the same TextInput events.
- Android: KeyCaptureView picks a real editable InputConnection when the host
has the cap — the IME runs its full machinery, mirrored to the host live via
common-prefix diffs of the composition (backspaces + new suffix), with
setComposingRegion adopting committed text so autocorrect-revert flows diff
instead of retyping; newline→Enter, deleteSurroundingText→Backspace/Delete.
Older hosts keep the TYPE_NULL raw-key path unchanged.
- keymap: media VKs (0xB0-0xB3) → evdev so the Android media keys land on
Linux hosts too.
Verified: punktfunk-core + host gamestream + pf-inject tests green on Linux
(Ubuntu box), clippy clean; Android app+native builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase A — hardware mouse: new MouseForwarder routes every SOURCE_MOUSE event
while streaming. Uncaptured (default): hover/drag positions forward as absolute
cursor moves, wheel with fractional accumulation (high-res wheels), all five
buttons incl. back/forward as X1/X2; the local cursor is hidden over the stream
(TYPE_NULL pointer icon) so the host's composited cursor is the visible one.
Captured ("Capture pointer for games" setting, or Ctrl+Alt+Shift+Q live): the
OS pointer is grabbed via requestPointerCapture on the (repurposed) key-capture
view and raw relative deltas forward as mouse-look; held buttons are tracked
and flushed on capture loss/teardown, and the engaging click is swallowed
(desktop parity). Mouse clicks are intercepted before Compose so they can never
be misread as trackpad taps; a mouse back-button's FALLBACK BACK is dropped so
it can't yank the user out of the stream.
Phase B — keyboard: media/consumer keys (play/pause/next/prev/stop) now map to
their VKs instead of falling to the system; TV-remote SELECT maps to Enter.
Phase D — Android TV: RemotePointer turns a D-pad-only remote into a pointer
(Siri-remote analogue): hold SELECT ~0.8s toggles, D-pad glides the host cursor
with ramped acceleration (Choreographer-paced, diagonal-normalized), SELECT
taps click, play/pause right-clicks, holding it toggles the on-screen keyboard,
BACK leaves pointer mode; an overlay hint teaches the vocabulary.
Settings: "Capture pointer for games" + "Invert scroll direction" (applies
to the wheel and two-finger touch scrolling).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lifts the ~100 Mbps decrypt ceiling on clients without hardware AES — the
armv7 soft-AES targets (webOS TVs), where AES-128-GCM resolves to fixsliced
software AES + software GHASH (~50-100 cpb) while ChaCha20-Poly1305's ARX
construction runs ~10-17 cpb portable, a 4-7x lift that PyroWave-on-TV needs
(design/chacha20-session-cipher.md).
Phase 1 (core crypto, no wire change): SessionKey merges cipher choice and
key material (invalid combinations unrepresentable, zeroize + redacted-Debug
discipline kept); SessionCrypto dispatches both aead-0.5 ciphers per call —
the salt||seq nonce scheme, per-direction salts, seq-as-AAD and replay
window carry over verbatim (same 96-bit nonce / 16-byte tag, const-asserted).
Config.key becomes SessionKey; validate's zero-key rejection follows the
active variant. The C ABI keeps its fixed 16-byte key mapped to AES — no
ABI_VERSION bump.
Phase 2 (negotiation): VIDEO_CAP_CHACHA20 (0x40) — support-plus-request in
one bit, the VIDEO_CAP_444 precedent. Welcome grows cipher@68 +
key_chacha@69..101, emitted only when non-zero so an AES session's Welcome
stays byte-identical to the pre-cipher form; decode is fail-closed (short
key or unknown id -> Err, never a silent AES fallback). No WIRE_VERSION
bump; downgrade resistance inherited from the pinned-TLS control channel.
Phase 3 (host): grant only when the client advertised the bit and the
PUNKTFUNK_CHACHA20 kill-switch (default on, documented) allows; fresh
32-byte per-session key from the same RNG discipline, legacy key field
stays independently random; resolved cipher logged at session start.
Verification: seal/open suites parameterized over both ciphers + a
cross-cipher tamper case; Welcome roundtrip/truncation/fail-closed tests;
ChaCha lossy-loopback soak (loss/replay is cipher-independent); bench
gains _chacha20 series (AES ids unchanged for CI history) — host-side
sealing line-rate-trivial on both x86 (~640 MiB/s) and Apple Silicon
(~535 MiB/s). punktfunk-probe drives the interop matrix via
PUNKTFUNK_CLIENT_CHACHA20=1 and logs the negotiated cipher.
Phase 4 (pf-webos pin bump + unconditional cap bit) follows the next
core release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI workflow assembled the store zip by hand and silently drifted from
scripts/package.sh when 60d46530 added assets/ and controller_config/ — every
published build (canary and stable through 0.17.1) was missing both, so the
Steam shortcut a fresh install creates had no images and no controller layout
(shortcut_art() found nothing to send; apply_controller_config failed with
template-missing). CI now stages through scripts/package.sh — the plugin file
list lives in one place — and a backstop verifies the runtime-loaded pieces are
in the zip before publishing.
Frontend hardening for installs poisoned by those builds: applyArtwork stamped
its per-appId ART_VERSION marker even when zero assets were applied, so
shipping the files alone would never heal existing shortcuts — only mark done
when something actually landed, and bump ART_VERSION 2→3 to force one re-apply.
Same guard for the controller-config marker (ok with zero account configset
dirs is not done), plus one deferred artwork retry for the fresh-AddShortcut
registration race setShortcutHidden already defers around.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launching from the Decky plugin against a sleeping host fired the
Wake-on-LAN packet and dialed immediately — but the dial was a single
quinn attempt that gave up after the transport's 8 s idle window, far
shorter than a suspend-to-RAM resume. The host woke to find the client
already gone (its retransmitted Initials show up host-side as
"QUIC accept failed error=timed out"), the stream shortcut exited, and
the launch looked cancelled.
- punktfunk-core: dial in a loop until the embedder's connect budget is
spent — short 3 s attempts keep the Initial cadence dense, so the
connect lands within ~a second of the host's network returning. Only
silence is retried: pin mismatches, typed rejections, and any real
answer surface immediately, and the shutdown flag stops the loop.
- decky: stretch the budget to 75 s when a magic packet actually went
out (PF_CONNECT_TIMEOUT → --connect-timeout via the wrapper), so the
retry window covers the resume; an awake host still connects in <1 s.
- host: a clean close before the control handshake is a reachability
probe (hosts-page online pips), not a failed session — log it at
debug instead of WARN "session ended with error", which buried the
real failures in the reporter's log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.
Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.
Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swift `Int` is 64-bit (Foundation infers %lld); the stats mirror's format
string used %d (32-bit C int) for fps/presents/lost. macOS 26's strict
String(format:) validator rejects the mismatch and drops the whole line
(the error also cascades onto the float args, mis-blaming floor_p50). Use
%lld for the three Int fields. Pre-existing, unrelated to the DCP work;
surfaced while reading presenter logs during the panic fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The windowed-macOS "mismatched swapID's" @UnifiedPipeline.cpp kernel
panic is the CAMetalLayer ASYNC image queue (commandBuffer.present,
mandatory with displaySyncEnabled=false) diverging from WindowServer's
compositor on a high-refresh composited session — it survived glass
pacing (PyroWave, 2026-07-18) and hit windowed HEVC on the same 240 Hz
Mac Studio (2026-07-21), so it's the async queue itself, at any codec
or pacing.
f407f418's mitigation routed windowed PyroWave through a BGRA8 IOSurface
layer.contents pool, which cannot carry HDR — extending it to HEVC would
have tone-mapped windowed HDR down to SDR. Instead, present the drawable
through a Core Animation transaction (CAMetalLayer.presentsWithTransaction)
for every windowed session: commit, waitUntilScheduled, then drawable.present()
inside a CATransaction, so the swap commits with the layer tree and stays
in lockstep with the compositor instead of racing it. The full
rgba16Float / PQ / EDR render path is untouched — windowed HDR is real
HDR again. Fullscreen keeps the async path (direct scanout, lowest latency,
no panic there).
Removes the IOSurface surface pool, its surfaceLayer sibling, and the
macOS windowed PQ->SDR tone-map (tvOS keeps its no-headroom tone-map).
Net -150 lines. swift build + 169 tests green. Needs on-glass soak on
the 240 Hz Mac Studio (kernel race — only real hardware confirms).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The probe's reassembler is the shared core one, and the probe is the tool
that measures the streamed-AU overlap win — advertise the cap so a host
with chunked encode streams to it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second and final batch from the 2026-07-20 core-sweep lows:
client:
- connect() timeout now sets `quit` before shutdown, so a handshake that
completes after the deadline closes with QUIT_CLOSE_CODE instead of
leaving the host lingering (virtual display up) for a reconnect that
never comes.
- probe_result: saturating_add on the wire-supplied wire_packets +
send_dropped counters (debug-build overflow panic / release wrap).
- the standing-latency bleed no longer sets flush_in_window: that flag
is the ABR's SEVERE (×0.7) verdict, and the bleed fires only on
provably loss-free windows the controller itself scores as fine.
clipboard:
- fetch_cancels pruned on every new fetch (was: one dead oneshot per
paste for the session).
- serve chunks gated on a parked waiter + capped at CLIP_FETCH_CAP with
an Error event (was: unbounded silent accumulation under any req_id).
- serve_inbound park bounded by FETCH_STALL_SECS + send.stopped() (was:
an unanswered FetchRequest parked the task, waiter, and bi-stream
forever; ~100 of them exhaust the connection's bidi budget).
C ABI (ABI_VERSION 9 → 10, header regenerated, additive only):
- new punktfunk_connection_clock_offset_now_ns — the LIVE re-synced
offset (Swift/Kotlin latency math read the frozen connect-time value
~40ms wrong after a wall-clock step).
- to_config: checked u64→usize narrowing of max_frame_bytes (32-bit
armeabi truncated >4GiB to a plausible residue).
- host_poll_input: no &mut held across the embedder callback (re-entry
aliased it — UB under noalias); mid-drain callback clears now stick.
- next_audio_pcm: DTX (empty) payloads skipped — decode synthesized
120ms of concealment per 5ms slot and grew the playout ring forever.
- next_clipboard releases the parked payload on an empty poll (a one-off
50MiB paste stayed resident all session).
- frames_dropped / wants_decode_latency write their documented 0/false
defaults before the NULL-handle check.
- gamepad constant docs match pick_gamepad() reality (DualSenseEdge/
SwitchPro landed; DualSense/DS4 honored on Windows UMDF too).
FEC:
- gf8 reconstruct/reconstruct_into reuse the (k,m) codec cache like
encode_into (was: fresh 230×200 generator + decode inversion per
lossy block on the pump thread).
- vendored fec-rs: the 8 safe wrap_mul_slice shims assert equal lengths
(x86 SIMD callees bound stores on input.len() — safe-code OOB write);
ReconstructShard's safety contract gains the len()==get().len()
clause and reconstruct_internal sizes raw slices from the slice.
transport/GTK:
- Linux GSO super-buffer capped at the real UDP payload ceiling (65487)
not 65535 — seg sizes ≥1024 could EMSGSIZE and latch GSO off
process-wide, blamed on the network.
- GTK settings dialog no longer rewrites an unlisted-but-valid stored
gamepad preference to "auto" on close.
Already fixed on main (verified stale, skipped): the reassembler FEC
ceiling, wants_decode_latency's third term, request_probe rollback +
the generalized probe watchdog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ci.yml`'s clippy gate has been failing on main since the pre-0.16.0 sweep landed, and
because clippy stops at the first crate it can't compile, the visible error was only
ever the first of four. `ci.yml` is not tag-triggered, so v0.16.0 cut and shipped over
a red main; none of this reaches the release artifacts (the two E0308s are in a test
binary and a dev tool, and the rest are lints) but the gate has been blind since.
Fixed, in the order clippy surfaced them:
- clients/probe failed to COMPILE (E0308, x2). `810d918d` moved `clock_sync` onto the
resumable `io::MsgReader` but only updated the client pump, leaving the probe passing
a bare `&mut RecvStream`. The probe now wraps the control stream in a `MsgReader` at
`open_bi` and threads that everywhere — Welcome, the --remode and --bitrate watchers,
and the speed-test result read — which is also what the refactor was for: those reads
sit behind timeouts and `select!`, exactly where a straddling frame would desync the
stream for the rest of the run.
- pf-capture: `SPA_META_Cursor as u32` is a `u32 -> u32` no-op
(`clippy::unnecessary_cast`). Line 1274 already passes the same constant uncast, so
the type is not in question.
- pf-inject: `noop as usize` on the SIGUSR1 wake handler added in `986402f7` trips
`clippy::function_casts_as_integer`; goes via `*const ()` as the lint asks. Same value
in the `usize`-typed `sa_sigaction` slot.
- punktfunk-core: the `ctrl_framing` test module's `use super::*` is unused, which
`-D warnings` promotes to an error.
Verified with CI's own commands on a Linux box (this is all Linux-gated code, so a Mac
cannot check it): `cargo clippy --workspace --all-targets --locked -- -D warnings`
finishes clean, `cargo build --workspace --locked` succeeds, and
`cargo test --workspace --locked` exits 0 with no failures — including punktfunk-core's
196-test suite, which covers the control-stream framing the probe change touches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-pair investigation (wired Mac clients stuck at a rock-steady
~18-19 ms "network" that survived the load ending and cleared only on
reconnect) exposed two structural gaps, one of measurement and one of
recovery:
- Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core,
Android decode loops), not at reassembly completion — so any client-side
standing state between the reassembler and the pull read as NETWORK
latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame`
grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses
the session boundary. Every embedder now uses the core stamp; the Apple
client keeps the pull instant as `AccessUnit.pulledNs` and shows the
receipt→pull wait as its own "client queue" term (detailed HUD tier from
2 ms + a `queue_p50` stats-log field). Decode stages keep their pull
anchor on all platforms, so no historical stage shifts meaning.
- The jump-to-live detectors deliberately ignore anything under 6 queued
frames / 400 ms behind — so a small, constant, loss-free elevation (a
sub-frame standing backlog, or a stale clock offset after a wall-clock
step/slew) is carried for the rest of the session. New third detector
(`StandingLatency`, unit-tested ladder): window-MIN one-way delay
≥ 10 ms above the session floor with zero loss for ~4.5 s escalates
gently — a free clock re-sync first (an applied re-sync re-bases the
floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live
cooldown, then a loud disarm naming what it means. Loss windows reset
the run: congestion belongs to FEC/ABR, not this detector.
Also: mid-stream re-sync apply/discard logs debug→info — they are the
forensic trail for the stale-offset case and were invisible in the field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both MSIX matrix jobs failed at the v0.15.0 tag, for two unrelated reasons, both
introduced by this release. 0.13.0 and 0.14.0 shipped both packages; 0.15.0 shipped
neither.
ARM64 failed to BUILD. The PyroWave Windows un-gating (1ef0229b) moved `pyrowave-sys`
in pf-client-core out of `cfg(target_os = "linux")` into a general optional dependency
that pf-client-core's own `default = ["pyrowave"]` turns on. The ARM64 leg builds
`--no-default-features` precisely to skip it, but that only disables defaults for the
two packages named with `-p` — every internal edge kept inheriting them:
clients/windows -> pf-client-core, clients/session -> pf-client-core, clients/session
-> pf-presenter, and pf-presenter's own `default = ["pyrowave"]` -> pf-client-core.
So the vendored Granite C++ compiled on ARM64 and stopped where it always does:
muglm.cpp reaching for _MM_TRANSPOSE4_PS/_mm_storeu_ps, then
`simd.hpp:103 #error "Implement me."`.
Those three internal edges now pass `default-features = false`; the feature is enabled
only through an explicit chain (clients/session's `pyrowave` -> pf-client-core/pyrowave
+ pf-presenter/pyrowave, and pf-presenter's `pyrowave` -> pf-client-core/pyrowave), so
`--no-default-features` finally means what it says. Verified by feature resolution
rather than assertion — `cargo tree -i pyrowave-sys`:
aarch64-pc-windows-msvc, --no-default-features -> not in the graph (was: present)
x86_64-pc-windows-msvc, default features -> present
x86_64-unknown-linux-gnu, default features -> present
PyroWave is NOT dropped from the Windows client. Decode has always run in the spawned
punktfunk-session binary, whose own default keeps the feature on, and unification turns
it back on for the shared pf-client-core wherever that binary is in the build (x64,
Linux). The shell only ever offered "pyrowave" as a codec preference string — it holds
no decoder and never called one, so linking the C++ into it was dead weight even on x64.
x64 built fine and failed at PACKAGING: `makepri new` rejected the manifest with
PRI191 "Appx manifest not found or is invalid" / malformed comment syntax. The console
tile added in 2508b720 documented its flag inside an XML comment, and XML forbids `--`
anywhere in a comment body, so the literal flag spelling made the whole manifest
unparseable. Reworded, with a note to keep the next person from reintroducing it.
Confirmed by parsing both revisions after template substitution: the tagged manifest
fails at line 63 col 9, this one parses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AdwPreferencesDialog parks its view switcher in a bottom bar whenever the
dialog is narrower than 110pt × page count (≈ 733 px for our five pages),
and the default float width (~640 px) is always under that — so the tabs
could never reach the header. 830 px puts them there for good (the tabbed
look the Apple and Windows clients share) with margin to spare, and gives
the caption-bearing rows room to breathe. A window too small to grant the
width still collapses the switcher to the bottom bar on its own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One page of everything becomes the cross-client map (the Apple 2026-07
revamp, same shape the Windows client just adopted): General / Display /
Input / Audio / Controllers pages with per-field captions, dynamic where the
meaning depends on the selection (touch mode, resolution, codec). About
stays in the primary menu per GNOME convention; settings search is on.
Combo-row captions are deliberately ONE line — a wrapped subtitle's natural
width crushes the selected-value label into an ellipsis.
New controls, all with existing plumbing: 10-bit HDR (hdr_enabled was
advertised but had no UI), PyroWave in the codec picker (preference-only,
Linux decodes it), a detected-controllers list, auto-wake on connect — gated
at the AppMsg::WakeConnect entry (off: no packet, no wake-and-wait fallback;
the card menu's explicit Wake stays), invert scroll (session plumbing landed
with the Windows parity series), and the GPU + audio endpoint pickers, fed
by a startup worker-thread probe (AppModel::probes) and hidden while empty.
The screenshot harness gains PUNKTFUNK_SHOT_SETTINGS_PAGE to open the
capture on a specific page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Settings GPU pick existed (adapter → PUNKTFUNK_VK_ADAPTER) but no Linux
shell could enumerate anything to pick: the GTK shell deliberately links no
Vulkan. pf_presenter::vk::list_adapters() reads the physical devices'
marketing names (no surface, discrete first, deduped — the name is the whole
match key in pick_device), surfaced as `punktfunk-session --list-adapters`.
Audio gets the same treatment for the new speaker_device/mic_device settings
(PipeWire node.name; empty = default): session main maps them onto
PUNKTFUNK_AUDIO_SINK/SOURCE — a hand-set env still wins, like the adapter —
and the playback/mic streams pass them as `target.object` (raw key: the
keys::TARGET_OBJECT constant is feature-gated on a newer libpipewire than we
require). pf_client_core::audio::devices() is the registry roundtrip the
pickers read, exposed for debugging as `punktfunk-session --list-audio`.
The WASAPI leg (Windows endpoint IDs) is still to come; the fields are
ignored there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POLL was dead: the local clipboard was re-read once per inbound event wait
(<=120 ms) instead of on its own 400 ms cadence, so the constant documenting
the interval described something the code did not do. Give it a deadline of
its own -- the event wait is short because it bounds teardown latency, which
is no reason to hammer the Win32 clipboard eight times a second while the
user is copying in another app.
Build State in one expression while here, and note for next time: CI runs
clippy as --workspace --all-targets -- -D warnings, so a scoped run without
-D warnings (what I did) does not reproduce it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three related bits of the same cleanup:
- The console UI moves from a card in the page body to a button in the
header row, beside Add host / Shortcuts / Settings. It also opens the
console's OWN host view now (bare --browse) rather than diving straight
into one host's library, which is what the card did -- the couch
counterpart of this page, not a shortcut past it.
- spawn_browse / open_console take an Option target to express that.
- The per-host overflow menu collapses: rename and the two clipboard labels
become one Edit dialog carrying every per-host property, mirroring the
Apple client's add/edit sheet (name, address, port, Wake-on-LAN MAC,
share clipboard). A menu item per field read as clutter and buried the
entries that matter.
Cleared fields mean leave-as-is rather than erase, except the MAC, which is
legitimately clearable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>